From 4ef99b1f81291f5b5e87c05f3688941b16f9afab Mon Sep 17 00:00:00 2001 From: Saufi Date: Tue, 2 Jun 2026 17:35:45 +0800 Subject: [PATCH] first --- .dockerignore | 15 + .editorconfig | 18 + .env.example | 90 + .gitattributes | 11 + .gitignore | 29 + .npmrc | 2 + ARCHITECTURE.md | 245 + DATABASE_DESIGN.md | 272 + DEPLOYMENT.md | 318 + Dockerfile | 52 + README.md | 58 + SECURITY_MODEL.md | 277 + TASK_PLAN.md | 342 + app/Actions/ActivateUserAction.php | 24 + app/Actions/ChangeUserEmailAction.php | 30 + app/Actions/CreateUserAction.php | 37 + app/Actions/DeactivateUserAction.php | 28 + app/Actions/DeleteUserAction.php | 37 + app/Actions/TransferOwnershipAction.php | 35 + .../Controllers/Admin/AuditLogController.php | 89 + .../Controllers/Admin/DashboardController.php | 65 + .../Admin/ProjectMetadataController.php | 65 + app/Http/Controllers/Admin/UserController.php | 106 + .../Auth/AuthenticatedSessionController.php | 58 + app/Http/Controllers/Controller.php | 13 + app/Http/Controllers/User/AudioController.php | 40 + .../User/CollaboratorController.php | 72 + .../Controllers/User/CommentController.php | 52 + .../Controllers/User/DashboardController.php | 26 + .../Controllers/User/ProjectController.php | 122 + .../Controllers/User/TranscriptController.php | 112 + .../User/TranscriptVersionController.php | 67 + app/Http/Middleware/EnsureAdmin.php | 19 + app/Http/Middleware/EnsureRegularUser.php | 19 + app/Http/Middleware/EnsureUserIsActive.php | 25 + app/Http/Middleware/SecurityHeaders.php | 41 + app/Http/Requests/Admin/CreateUserRequest.php | 36 + .../Admin/TransferOwnershipRequest.php | 29 + .../Requests/Admin/UpdateUserEmailRequest.php | 32 + app/Http/Requests/Auth/LoginRequest.php | 64 + .../Requests/User/CreateProjectRequest.php | 52 + .../Requests/User/StoreCommentRequest.php | 28 + .../Requests/User/UpdateTranscriptRequest.php | 30 + .../User/UploadExternalTranscriptRequest.php | 32 + app/Jobs/OllamaPostProcessJob.php | 76 + app/Jobs/TranscribeAudioJob.php | 120 + app/Models/AuditLog.php | 46 + app/Models/Department.php | 24 + app/Models/ProjectCollaborator.php | 26 + app/Models/ProjectComment.php | 24 + app/Models/TranscriptVersion.php | 36 + app/Models/TranscriptionProject.php | 153 + app/Models/User.php | 106 + app/Policies/CommentPolicy.php | 26 + app/Policies/TranscriptVersionPolicy.php | 26 + app/Policies/TranscriptionProjectPolicy.php | 90 + app/Policies/UserPolicy.php | 45 + app/Providers/AppServiceProvider.php | 40 + app/Rules/ValidAudioMagicBytes.php | 77 + app/Services/AuditLogService.php | 35 + app/Services/OllamaService.php | 71 + app/Services/StorageService.php | 45 + artisan | 18 + bootstrap/app.php | 31 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 86 + composer.lock | 8274 +++++++++++++++++ config/app.php | 126 + config/auth.php | 117 + config/cache.php | 136 + config/database.php | 184 + config/filesystems.php | 89 + config/logging.php | 132 + config/mail.php | 118 + config/queue.php | 129 + config/services.php | 38 + config/session.php | 233 + config/speech2text.php | 60 + database/.gitignore | 1 + .../factories/TranscriptionProjectFactory.php | 53 + database/factories/UserFactory.php | 43 + .../0001_01_01_000000_create_users_table.php | 57 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 59 + ...4_01_01_000010_create_audit_logs_table.php | 36 + ...20_create_transcription_projects_table.php | 76 + database/seeders/AdminUserSeeder.php | 41 + database/seeders/DatabaseSeeder.php | 16 + database/seeders/DepartmentSeeder.php | 29 + docker-compose.yml | 167 + docker/nginx/default.conf | 40 + docker/php-apache/Dockerfile | 25 + docker/php-apache/vhost.conf | 13 + docker/php/custom.ini | 4 + docker/php/php.ini | 34 + docker/supervisor/supervisord.conf | 27 + docker/transcription-worker/Dockerfile | 18 + docker/transcription-worker/main.py | 125 + docker/transcription-worker/requirements.txt | 6 + package-lock.json | 1656 ++++ package.json | 16 + phpunit.xml | 38 + public/.htaccess | 25 + public/favicon.ico | 0 public/index.php | 20 + public/robots.txt | 2 + resources/css/app.css | 11 + resources/js/app.js | 1 + .../views/admin/audit-logs/index.blade.php | 150 + resources/views/admin/dashboard.blade.php | 254 + .../views/admin/projects/index.blade.php | 215 + resources/views/admin/users/create.blade.php | 93 + resources/views/admin/users/edit.blade.php | 81 + resources/views/admin/users/index.blade.php | 125 + resources/views/auth/login.blade.php | 103 + resources/views/layouts/admin.blade.php | 128 + resources/views/layouts/app.blade.php | 118 + resources/views/user/dashboard.blade.php | 117 + .../views/user/projects/create.blade.php | 118 + resources/views/user/projects/show.blade.php | 465 + .../views/user/projects/versions.blade.php | 109 + resources/views/welcome.blade.php | 223 + routes/console.php | 8 + routes/web.php | 114 + scripts/backup-db.sh | 25 + speech2text | Bin 0 -> 176128 bytes storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 9 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/Admin/Phase5Test.php | 257 + tests/Feature/Admin/UserManagementTest.php | 166 + tests/Feature/Auth/LoginTest.php | 68 + tests/Feature/ExampleTest.php | 13 + tests/Feature/Project/ProjectAccessTest.php | 245 + .../Feature/Project/TranscriptEditorTest.php | 463 + .../Feature/Project/TranscriptionJobTest.php | 397 + tests/Feature/Security/Phase6Test.php | 265 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 24 + 148 files changed, 21134 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 ARCHITECTURE.md create mode 100644 DATABASE_DESIGN.md create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SECURITY_MODEL.md create mode 100644 TASK_PLAN.md create mode 100644 app/Actions/ActivateUserAction.php create mode 100644 app/Actions/ChangeUserEmailAction.php create mode 100644 app/Actions/CreateUserAction.php create mode 100644 app/Actions/DeactivateUserAction.php create mode 100644 app/Actions/DeleteUserAction.php create mode 100644 app/Actions/TransferOwnershipAction.php create mode 100644 app/Http/Controllers/Admin/AuditLogController.php create mode 100644 app/Http/Controllers/Admin/DashboardController.php create mode 100644 app/Http/Controllers/Admin/ProjectMetadataController.php create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 app/Http/Controllers/Auth/AuthenticatedSessionController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/User/AudioController.php create mode 100644 app/Http/Controllers/User/CollaboratorController.php create mode 100644 app/Http/Controllers/User/CommentController.php create mode 100644 app/Http/Controllers/User/DashboardController.php create mode 100644 app/Http/Controllers/User/ProjectController.php create mode 100644 app/Http/Controllers/User/TranscriptController.php create mode 100644 app/Http/Controllers/User/TranscriptVersionController.php create mode 100644 app/Http/Middleware/EnsureAdmin.php create mode 100644 app/Http/Middleware/EnsureRegularUser.php create mode 100644 app/Http/Middleware/EnsureUserIsActive.php create mode 100644 app/Http/Middleware/SecurityHeaders.php create mode 100644 app/Http/Requests/Admin/CreateUserRequest.php create mode 100644 app/Http/Requests/Admin/TransferOwnershipRequest.php create mode 100644 app/Http/Requests/Admin/UpdateUserEmailRequest.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php create mode 100644 app/Http/Requests/User/CreateProjectRequest.php create mode 100644 app/Http/Requests/User/StoreCommentRequest.php create mode 100644 app/Http/Requests/User/UpdateTranscriptRequest.php create mode 100644 app/Http/Requests/User/UploadExternalTranscriptRequest.php create mode 100644 app/Jobs/OllamaPostProcessJob.php create mode 100644 app/Jobs/TranscribeAudioJob.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/Department.php create mode 100644 app/Models/ProjectCollaborator.php create mode 100644 app/Models/ProjectComment.php create mode 100644 app/Models/TranscriptVersion.php create mode 100644 app/Models/TranscriptionProject.php create mode 100644 app/Models/User.php create mode 100644 app/Policies/CommentPolicy.php create mode 100644 app/Policies/TranscriptVersionPolicy.php create mode 100644 app/Policies/TranscriptionProjectPolicy.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Rules/ValidAudioMagicBytes.php create mode 100644 app/Services/AuditLogService.php create mode 100644 app/Services/OllamaService.php create mode 100644 app/Services/StorageService.php create mode 100644 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 config/speech2text.php create mode 100644 database/.gitignore create mode 100644 database/factories/TranscriptionProjectFactory.php create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2024_01_01_000010_create_audit_logs_table.php create mode 100644 database/migrations/2024_01_01_000020_create_transcription_projects_table.php create mode 100644 database/seeders/AdminUserSeeder.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/DepartmentSeeder.php create mode 100644 docker-compose.yml create mode 100644 docker/nginx/default.conf create mode 100644 docker/php-apache/Dockerfile create mode 100644 docker/php-apache/vhost.conf create mode 100644 docker/php/custom.ini create mode 100644 docker/php/php.ini create mode 100644 docker/supervisor/supervisord.conf create mode 100644 docker/transcription-worker/Dockerfile create mode 100644 docker/transcription-worker/main.py create mode 100644 docker/transcription-worker/requirements.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/views/admin/audit-logs/index.blade.php create mode 100644 resources/views/admin/dashboard.blade.php create mode 100644 resources/views/admin/projects/index.blade.php create mode 100644 resources/views/admin/users/create.blade.php create mode 100644 resources/views/admin/users/edit.blade.php create mode 100644 resources/views/admin/users/index.blade.php create mode 100644 resources/views/auth/login.blade.php create mode 100644 resources/views/layouts/admin.blade.php create mode 100644 resources/views/layouts/app.blade.php create mode 100644 resources/views/user/dashboard.blade.php create mode 100644 resources/views/user/projects/create.blade.php create mode 100644 resources/views/user/projects/show.blade.php create mode 100644 resources/views/user/projects/versions.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 scripts/backup-db.sh create mode 100644 speech2text create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/Admin/Phase5Test.php create mode 100644 tests/Feature/Admin/UserManagementTest.php create mode 100644 tests/Feature/Auth/LoginTest.php create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/Project/ProjectAccessTest.php create mode 100644 tests/Feature/Project/TranscriptEditorTest.php create mode 100644 tests/Feature/Project/TranscriptionJobTest.php create mode 100644 tests/Feature/Security/Phase6Test.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0f003b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +node_modules +npm-debug.log +storage/app/private +storage/logs +storage/framework/cache +storage/framework/sessions +storage/framework/views +.env +.env.* +!.env.example +*.md +docker/transcription-worker +tests diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba79e1b --- /dev/null +++ b/.env.example @@ -0,0 +1,90 @@ +APP_NAME="Speech2Text MBIP" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://speech2text.mbip.my + +APP_LOCALE=ms +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=ms_MY + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=error + +DB_CONNECTION=mysql +DB_HOST=mysql +DB_PORT=3306 +DB_DATABASE=speech2text +DB_USERNAME=speech2text +DB_PASSWORD= + +SESSION_DRIVER=redis +SESSION_LIFETIME=120 +SESSION_ENCRYPT=true +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=redis + +CACHE_STORE=redis + +REDIS_CLIENT=phpredis +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="noreply@mbip.my" +MAIL_FROM_NAME="${APP_NAME}" + +# ============================================================ +# Storage +# ============================================================ +PRIVATE_AUDIO_MAX_MB=200 + +# ============================================================ +# Transcription Engine +# ============================================================ +TRANSCRIPTION_ENGINE=faster-whisper +WHISPER_MODEL=small +WHISPER_LANGUAGE=ms +WHISPER_DEVICE=cpu +WHISPER_COMPUTE_TYPE=int8 +TRANSCRIPTION_WORKER_URL=http://transcription-worker:8000 + +# ============================================================ +# Ollama (optional — local LLM post-processing) +# Default: disabled. Jangan aktifkan jika tidak diperlukan. +# ============================================================ +OLLAMA_ENABLED=false +OLLAMA_BASE_URL=http://ollama:11434 +OLLAMA_MODEL=llama3.1 + +# ============================================================ +# Admin Default (untuk seeder pertama kali) +# Tukar password selepas login pertama! +# ============================================================ +ADMIN_DEFAULT_NAME= +ADMIN_DEFAULT_EMAIL= +ADMIN_DEFAULT_PASSWORD= + +# ============================================================ +# MySQL Root (untuk Docker) +# ============================================================ +MYSQL_ROOT_PASSWORD= +MYSQL_DATABASE=speech2text +MYSQL_USER=speech2text +MYSQL_PASSWORD= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..837fe36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.log +.DS_Store +.env +.env.* +!.env.example +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..0cfaf02 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,245 @@ +# ARCHITECTURE.md — Speech2Text MBIP + +## Gambaran Keseluruhan + +Sistem Speech-to-Text MBIP ialah aplikasi web dalaman yang membolehkan pengguna jabatan muat naik fail audio dan mendapatkan transkripsi teks Bahasa Melayu secara automatik. Semua pemprosesan berlaku dalam persekitaran Docker yang self-hosted, tanpa hantar data ke cloud luaran. + +--- + +## Stack Teknologi + +| Lapisan | Teknologi | +|---|---| +| Web Framework | Laravel 11 (PHP 8.3+) | +| Database | MySQL 8 | +| Cache / Queue | Redis | +| Frontend | Bootstrap 5 + jQuery | +| Web Server | Nginx + PHP-FPM | +| Queue Worker | Laravel Queue (Redis driver) | +| Transcription Engine | faster-whisper (Python) via HTTP API | +| Post-processing (optional) | Ollama (local LLM) | +| Container | Docker Compose | +| Storage | Laravel Private Disk (local filesystem) | + +--- + +## Seni Bina Perkhidmatan (Docker Compose) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DOCKER NETWORK │ +│ │ +│ ┌───────────┐ ┌───────────┐ ┌──────────────────────┐ │ +│ │ nginx │───▶│ app │───▶│ mysql │ │ +│ │ :80/443 │ │ (php-fpm) │ │ :3306 │ │ +│ └───────────┘ └─────┬─────┘ └──────────────────────┘ │ +│ │ │ +│ ┌────▼────┐ ┌──────────────────────┐ │ +│ │ redis │ │ transcription-worker│ │ +│ │ :6379 │ │ (Python FastAPI) │ │ +│ └─────────┘ │ :8000 │ │ +│ │ └──────────────────────┘ │ +│ ┌──────────┴───┐ ┌──────────────────────┐ │ +│ │queue-worker │ │ ollama (optional) │ │ +│ │(Laravel) │ │ :11434 │ │ +│ └──────────────┘ └──────────────────────┘ │ +│ ┌──────────────┐ │ +│ │ scheduler │ │ +│ │(Laravel cron)│ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Perkhidmatan Docker + +#### `app` — Laravel PHP-FPM +- Image: php:8.3-fpm (custom Dockerfile) +- Mount: `/svr/speech2text/app` → `/var/www/html` +- Tanggungjawab: serve HTTP request, jalankan Artisan commands + +#### `nginx` +- Image: nginx:alpine +- Port: 80, 443 +- Proxy pass ke `app:9000` + +#### `mysql` +- Image: mysql:8 +- Volume: data persistent +- Database: `speech2text` + +#### `redis` +- Image: redis:alpine +- Digunakan untuk: queue, cache, session + +#### `queue-worker` +- Build dari image `app` yang sama +- Command: `php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600` +- Proses job transcription, notification, audit + +#### `scheduler` +- Build dari image `app` yang sama +- Command: `php artisan schedule:work` +- Proses scheduled tasks (cleanup, retention policy) + +#### `transcription-worker` +- Image: Python 3.11 + faster-whisper (custom Dockerfile) +- Port: 8000 (internal sahaja) +- Expose endpoint `/transcribe` (POST) +- Tidak accessible dari luar Docker network + +#### `ollama` (optional) +- Image: ollama/ollama +- Hanya diaktifkan jika `OLLAMA_ENABLED=true` +- Digunakan untuk post-processing teks selepas transcription + +--- + +## Aliran Data Transcription + +``` +User Upload Audio + │ + ▼ +[Laravel Controller] + - Validate file (type, size) + - Simpan ke storage/app/private/transcriptions/{uuid}/audio/ + - Cipta rekod TranscriptionProject (status: pending) + - Dispatch TranscribeAudioJob ke Redis queue + │ + ▼ +[Queue Worker — TranscribeAudioJob] + - Update status: processing + - Baca fail audio dari private storage + - Hantar ke transcription-worker via HTTP POST + │ + ▼ +[transcription-worker — Python FastAPI] + - Terima audio (base64 atau multipart) + - Jalankan faster-whisper dengan model yang dikonfig + - Return transcript JSON + │ + ▼ +[Queue Worker — callback] + - Simpan transcript ke database + - Update status: completed / failed + - Log audit + - (Optional) Dispatch OllamaPostProcessJob + │ + ▼ +[User melihat hasil transcript] +``` + +--- + +## Struktur Direktori Laravel + +``` +app/ +├── Actions/ # Single-purpose action classes +│ ├── CreateUserAction.php +│ ├── DeactivateUserAction.php +│ ├── TransferProjectOwnerAction.php +│ └── ... +├── Http/ +│ ├── Controllers/ +│ │ ├── Admin/ +│ │ │ ├── DashboardController.php +│ │ │ ├── UserController.php +│ │ │ ├── ProjectMetadataController.php +│ │ │ ├── TransferOwnershipController.php +│ │ │ └── AuditLogController.php +│ │ └── User/ +│ │ ├── DashboardController.php +│ │ ├── ProjectController.php +│ │ ├── AudioController.php +│ │ ├── TranscriptController.php +│ │ ├── CollaboratorController.php +│ │ ├── CommentController.php +│ │ └── TranscriptVersionController.php +│ ├── Requests/ # Form Request validation +│ └── Middleware/ +│ ├── EnsureUserIsActive.php +│ └── EnsureAdminCannotAccessContent.php +├── Jobs/ +│ ├── TranscribeAudioJob.php +│ └── OllamaPostProcessJob.php +├── Models/ +│ ├── User.php +│ ├── Department.php +│ ├── TranscriptionProject.php +│ ├── ProjectCollaborator.php +│ ├── TranscriptVersion.php +│ ├── ProjectComment.php +│ └── AuditLog.php +├── Policies/ +│ ├── TranscriptionProjectPolicy.php +│ ├── CommentPolicy.php +│ ├── TranscriptVersionPolicy.php +│ └── UserPolicy.php +├── Services/ +│ ├── TranscriptionService.php +│ ├── OllamaService.php +│ ├── AuditLogService.php +│ └── StorageService.php +└── ... + +resources/ +├── views/ +│ ├── layouts/ +│ │ ├── app.blade.php # Layout pengguna +│ │ └── admin.blade.php # Layout admin +│ ├── auth/ +│ ├── admin/ +│ │ ├── dashboard.blade.php +│ │ ├── users/ +│ │ ├── projects/ +│ │ └── audit-logs/ +│ └── user/ +│ ├── dashboard.blade.php +│ ├── projects/ +│ └── ... +``` + +--- + +## Authorization Model (Ringkas) + +``` +Admin: + ✓ Urus pengguna (CRUD metadata) + ✓ Lihat statistik + ✓ Lihat metadata projek sahaja + ✓ Transfer ownership dengan justifikasi + ✓ Lihat audit log pentadbiran + ✗ Dengar / download audio + ✗ Baca transcript + ✗ Lihat komen + ✗ Masuk detail projek (kandungan) + +Owner: + ✓ Semua akses ke projek sendiri + ✓ Urus collaborators + ✓ Delete projek / audio / teks + ✓ Retry transcription + +Collaborator: + ✓ Lihat / edit transcript + ✓ Dengar audio + ✓ Buat komen + ✗ Delete projek + ✗ Transfer ownership + ✗ Urus collaborators lain +``` + +--- + +## Prinsip Reka Bentuk + +1. **Thin Controller** — logic dalam Action class atau Service, bukan dalam controller. +2. **Policy Enforcement** — setiap action semak Policy, bukan hanya UI. +3. **Private Storage** — tiada fail audio/teks dalam `public/` atau `storage/app/public/`. +4. **UUID Routes** — gunakan UUID bukan ID integer dalam URL projek. +5. **Audit Everything** — semua tindakan sensitif direkod dalam `audit_logs`. +6. **Queue All Transcription** — proses transcription tidak boleh block HTTP request. +7. **No Cloud Dependency** — semua AI/ML processing dalam Docker network sahaja. +8. **Local-only Default** — Ollama disabled by default; hanya aktif jika eksplisit dikonfig. diff --git a/DATABASE_DESIGN.md b/DATABASE_DESIGN.md new file mode 100644 index 0000000..a74b1f6 --- /dev/null +++ b/DATABASE_DESIGN.md @@ -0,0 +1,272 @@ +# DATABASE_DESIGN.md — Speech2Text MBIP + +## Senarai Jadual + +1. `users` +2. `departments` +3. `transcription_projects` +4. `project_collaborators` +5. `transcript_versions` +6. `project_comments` +7. `audit_logs` +8. `sessions` +9. `jobs` / `failed_jobs` +10. `cache` + +--- + +## Skema Jadual + +### 1. `users` + +```sql +CREATE TABLE users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'user') NOT NULL DEFAULT 'user', + department_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_login_at TIMESTAMP NULL, + email_verified_at TIMESTAMP NULL, + remember_token VARCHAR(100) NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, -- soft delete + + FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL +); +``` + +**Catatan:** +- `deleted_at` hanya digunakan jika pengguna belum pernah guna aplikasi (tiada projek, tiada audit). +- Jika pernah guna, hanya `is_active = 0` (deactivate), jangan hard delete. +- `role` enum mudah diurus; boleh upgrade ke `spatie/laravel-permission` kemudian jika perlu. + +--- + +### 2. `departments` + +```sql +CREATE TABLE departments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + code VARCHAR(50) NULL UNIQUE, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL +); +``` + +--- + +### 3. `transcription_projects` + +```sql +CREATE TABLE transcription_projects ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL UNIQUE, -- digunakan dalam URL + title VARCHAR(255) NOT NULL, + description TEXT NULL, + owner_user_id BIGINT UNSIGNED NOT NULL, + original_filename VARCHAR(500) NOT NULL, + stored_audio_path VARCHAR(1000) NOT NULL, -- path relatif dalam private disk + mime_type VARCHAR(100) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL, -- bytes + duration_seconds INT UNSIGNED NULL, + language VARCHAR(10) NOT NULL DEFAULT 'ms', + transcription_status ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending', + transcription_engine VARCHAR(50) NULL, -- e.g. 'faster-whisper' + transcript_text LONGTEXT NULL, -- kandungan sensitif + transcript_confidence DECIMAL(5,4) NULL, -- 0.0000 - 1.0000 + error_message TEXT NULL, + processed_at TIMESTAMP NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, -- soft delete + + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE RESTRICT +); +``` + +**Catatan keselamatan:** +- `stored_audio_path` adalah path dalam private storage, bukan URL awam. +- `transcript_text` disimpan dalam database. Untuk keselamatan lanjut, boleh encrypt menggunakan Laravel `encrypted` cast. +- Admin **tidak** boleh SELECT `transcript_text`, `stored_audio_path` melalui policy/query scope. + +--- + +### 4. `project_collaborators` + +```sql +CREATE TABLE project_collaborators ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + project_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role ENUM('editor', 'viewer') NOT NULL DEFAULT 'editor', + added_by BIGINT UNSIGNED NOT NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + + UNIQUE KEY unique_project_user (project_id, user_id), + FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE RESTRICT +); +``` + +--- + +### 5. `transcript_versions` + +```sql +CREATE TABLE transcript_versions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + project_id BIGINT UNSIGNED NOT NULL, + edited_by BIGINT UNSIGNED NOT NULL, + version_number INT UNSIGNED NOT NULL, + old_text LONGTEXT NULL, + new_text LONGTEXT NOT NULL, + change_summary VARCHAR(500) NULL, + created_at TIMESTAMP NULL, + + FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE, + FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE RESTRICT +); +``` + +**Catatan:** +- `old_text` dan `new_text` adalah snapshot penuh, bukan diff, untuk kemudahan restore. +- Jangan masukkan `transcript_text` dalam `audit_logs`; gunakan jadual ini sebagai ganti. +- Admin tidak boleh akses jadual ini. + +--- + +### 6. `project_comments` + +```sql +CREATE TABLE project_comments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + project_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMP NULL, + updated_at TIMESTAMP NULL, + deleted_at TIMESTAMP NULL, -- soft delete + + FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT +); +``` + +--- + +### 7. `audit_logs` + +```sql +CREATE TABLE audit_logs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor_user_id BIGINT UNSIGNED NULL, -- NULL jika sistem/background job + actor_role VARCHAR(50) NULL, + action VARCHAR(100) NOT NULL, -- e.g. 'user_deactivated' + subject_type VARCHAR(100) NULL, -- e.g. 'App\Models\User' + subject_id BIGINT UNSIGNED NULL, + target_user_id BIGINT UNSIGNED NULL, + project_id BIGINT UNSIGNED NULL, + old_values JSON NULL, -- JANGAN masukkan transcript content + new_values JSON NULL, -- JANGAN masukkan transcript content + justification TEXT NULL, + ip_address VARCHAR(45) NULL, -- support IPv6 + user_agent TEXT NULL, + created_at TIMESTAMP NULL, + + INDEX idx_actor (actor_user_id), + INDEX idx_action (action), + INDEX idx_project (project_id), + INDEX idx_created (created_at) +); +``` + +**Tindakan yang diaudit:** + +| action | Penerangan | +|---|---| +| `user_created` | Admin daftar pengguna baru | +| `user_deactivated` | Admin deactivate pengguna | +| `user_reactivated` | Admin aktifkan semula pengguna | +| `user_deleted` | Admin delete pengguna (hanya jika belum guna) | +| `user_email_changed` | Admin tukar emel pengguna | +| `project_created` | Pengguna cipta projek | +| `audio_uploaded` | Pengguna muat naik audio | +| `transcription_started` | Queue worker mula proses | +| `transcription_completed` | Queue worker selesai | +| `transcription_failed` | Queue worker gagal | +| `transcript_updated` | Owner/collaborator edit teks | +| `transcript_version_restored` | Restore versi lama | +| `collaborator_added` | Owner tambah collaborator | +| `collaborator_removed` | Owner buang collaborator | +| `comment_created` | Pengguna buat komen | +| `project_deleted` | Owner delete projek | +| `project_owner_transferred` | Admin transfer ownership | + +--- + +## Hubungan Model (Eloquent Relationships) + +``` +User + ├── hasMany: TranscriptionProject (as owner) + ├── belongsToMany: TranscriptionProject (through ProjectCollaborator) + ├── hasMany: TranscriptVersion (as editor) + ├── hasMany: ProjectComment + ├── belongsTo: Department + └── hasMany: AuditLog (as actor) + +TranscriptionProject + ├── belongsTo: User (owner) + ├── hasMany: ProjectCollaborator + ├── hasMany: TranscriptVersion + ├── hasMany: ProjectComment + └── belongsToMany: User (collaborators) + +Department + └── hasMany: User +``` + +--- + +## Indeks Penting + +```sql +-- Cari projek mengikut status (untuk admin dashboard) +ALTER TABLE transcription_projects ADD INDEX idx_status (transcription_status); + +-- Cari projek mengikut owner +ALTER TABLE transcription_projects ADD INDEX idx_owner (owner_user_id); + +-- Cari versi mengikut projek (timeline) +ALTER TABLE transcript_versions ADD INDEX idx_project_version (project_id, version_number); + +-- Audit log search +ALTER TABLE audit_logs ADD INDEX idx_target_user (target_user_id); +ALTER TABLE audit_logs ADD INDEX idx_subject (subject_type, subject_id); +``` + +--- + +## Nota Keselamatan Data + +1. **`transcript_text`** — Kolum sensitif. Boleh encrypt menggunakan Laravel cast `encrypted`: + ```php + protected $casts = [ + 'transcript_text' => 'encrypted', + ]; + ``` + Ini encrypt menggunakan `APP_KEY`. Pastikan `APP_KEY` disimpan dengan selamat. + +2. **`stored_audio_path`** — Simpan path relatif sahaja, bukan absolute path. Contoh: `transcriptions/abc-uuid/audio/recording.mp3`. + +3. **Audit log** — Jangan masukkan `transcript_text` dalam `old_values` atau `new_values`. Gunakan `transcript_versions` untuk simpan snapshot teks. + +4. **Soft delete** — `transcription_projects` dan `project_comments` menggunakan soft delete. Fail audio fizikal dikekalkan dalam private storage sehingga admin jalankan retention cleanup. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..a2c1799 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,318 @@ +# DEPLOYMENT.md — Speech2Text MBIP + +## Keperluan Server + +| Komponen | Minimum | Disyorkan | +|---|---|---| +| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS | +| CPU | 4 core | 8 core | +| RAM | 8 GB | 16 GB | +| Storage | 100 GB | 500 GB+ (untuk audio files) | +| Docker | 24.x | Latest stable | +| Docker Compose | 2.x | Latest stable | + +**Nota:** RAM tambahan diperlukan jika menggunakan Whisper model yang lebih besar (medium/large). + +--- + +## Struktur Direktori Server + +``` +/svr/speech2text/ +├── .env # environment variables (JANGAN commit ke git) +├── .env.example # template (commit ke git) +├── docker-compose.yml # development / staging +├── docker-compose.prod.yml # production override +├── Dockerfile # Laravel app image +├── docker/ +│ ├── nginx/ +│ │ └── default.conf +│ ├── php/ +│ │ └── php.ini +│ ├── supervisor/ +│ │ └── supervisord.conf +│ └── transcription-worker/ +│ └── Dockerfile +├── app/ # Laravel source code +│ ├── storage/ +│ │ └── app/ +│ │ └── private/ # fail audio (tidak accessible web) +│ └── ... +└── mysql-data/ # MySQL data volume (auto-created) +``` + +--- + +## Langkah Deployment Pertama Kali + +### 1. Persediaan Server + +```bash +# Update server +sudo apt update && sudo apt upgrade -y + +# Install Docker +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER +newgrp docker + +# Verify +docker --version +docker compose version +``` + +### 2. Clone Repository + +```bash +sudo mkdir -p /svr/speech2text +sudo chown $USER:$USER /svr/speech2text +cd /svr/speech2text +git clone . +``` + +### 3. Konfigurasi Environment + +```bash +cp .env.example .env +nano .env # atau gunakan editor pilihan anda +``` + +Nilai wajib diisi dalam `.env`: + +``` +APP_KEY= # akan dijanakan kemudian +DB_PASSWORD= +ADMIN_DEFAULT_NAME= +ADMIN_DEFAULT_EMAIL= +ADMIN_DEFAULT_PASSWORD= +``` + +### 4. Build dan Jalankan Container + +```bash +cd /svr/speech2text +docker compose up -d --build +``` + +### 5. Setup Laravel + +```bash +# Jana APP_KEY +docker compose exec app php artisan key:generate + +# Jalankan migration dan seeder +docker compose exec app php artisan migrate --seed + +# Set permission storage +docker compose exec app chmod -R 775 storage bootstrap/cache +docker compose exec app chown -R www-data:www-data storage bootstrap/cache +``` + +### 6. Verify Services + +```bash +# Semak semua container berjalan +docker compose ps + +# Semak logs +docker compose logs -f app +docker compose logs -f queue-worker +docker compose logs -f transcription-worker + +# Semak queue worker +docker compose exec app php artisan queue:monitor +``` + +--- + +## Deployment Update (Selepas Push Kod Baru) + +```bash +cd /svr/speech2text + +# Pull kod terbaru +git pull origin main + +# Rebuild container jika ada perubahan Dockerfile +docker compose up -d --build app + +# Atau restart tanpa rebuild jika hanya kod PHP berubah +docker compose restart app queue-worker scheduler + +# Jalankan migration baru (jika ada) +docker compose exec app php artisan migrate --force + +# Clear cache +docker compose exec app php artisan config:cache +docker compose exec app php artisan route:cache +docker compose exec app php artisan view:cache + +# Restart queue worker untuk ambil kod baru +docker compose restart queue-worker +``` + +--- + +## Production Override (`docker-compose.prod.yml`) + +```bash +# Jalankan dengan production config +docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +``` + +--- + +## Konfigurasi Nginx (Luar Docker — jika ada reverse proxy) + +Jika server ada Nginx luar Docker sebagai reverse proxy: + +```nginx +server { + listen 443 ssl; + server_name speech2text.mbip.my; + + ssl_certificate /etc/ssl/certs/mbip.crt; + ssl_certificate_key /etc/ssl/private/mbip.key; + + location / { + proxy_pass http://127.0.0.1:8080; # port Docker nginx + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + client_max_body_size 210M; # PRIVATE_AUDIO_MAX_MB + buffer + } +} + +server { + listen 80; + server_name speech2text.mbip.my; + return 301 https://$host$request_uri; +} +``` + +--- + +## Backup + +### Database Backup + +```bash +# Backup manual +docker compose exec mysql mysqldump \ + -u root -p$DB_PASSWORD \ + speech2text > backup_$(date +%Y%m%d_%H%M%S).sql + +# Compress +gzip backup_*.sql +``` + +### Storage Backup + +```bash +# Backup fail audio (private storage) +tar -czf audio_backup_$(date +%Y%m%d).tar.gz \ + /svr/speech2text/app/storage/app/private/ +``` + +### Automated Backup (crontab) + +```bash +# Edit crontab +crontab -e + +# Tambah: +0 2 * * * cd /svr/speech2text && docker compose exec -T mysql \ + mysqldump -u root -p$DB_PASSWORD speech2text | \ + gzip > /backup/db/speech2text_$(date +\%Y\%m\%d).sql.gz +``` + +--- + +## Monitoring + +```bash +# Semak status container +docker compose ps + +# Lihat penggunaan resource +docker stats + +# Lihat logs real-time +docker compose logs -f + +# Logs spesifik service +docker compose logs -f queue-worker +docker compose logs -f transcription-worker + +# Semak queue jobs pending +docker compose exec app php artisan queue:monitor + +# Semak failed jobs +docker compose exec app php artisan queue:failed +``` + +--- + +## Troubleshooting + +### Container tidak start + +```bash +docker compose logs +docker compose config # semak syntax docker-compose.yml +``` + +### Transcription gagal + +```bash +# Semak transcription worker +docker compose logs transcription-worker + +# Test transcription worker secara manual +docker compose exec app curl http://transcription-worker:8000/health + +# Retry failed jobs +docker compose exec app php artisan queue:retry all +``` + +### Storage permission error + +```bash +docker compose exec app chmod -R 775 storage bootstrap/cache +docker compose exec app chown -R www-data:www-data storage +``` + +### Database migration error + +```bash +# Semak status migration +docker compose exec app php artisan migrate:status + +# Rollback jika perlu +docker compose exec app php artisan migrate:rollback +``` + +--- + +## Checklist Pre-Production + +- [ ] `APP_ENV=production` +- [ ] `APP_DEBUG=false` +- [ ] `APP_KEY` telah dijanakan dan disimpan dengan selamat +- [ ] Database password kuat (minimum 16 karakter, alphanumeric + simbol) +- [ ] HTTPS/TLS dikonfig +- [ ] Firewall: hanya port 80/443 terbuka dari internet +- [ ] Port 3306 (MySQL), 6379 (Redis), 8000 (transcription-worker) TIDAK terbuka dari internet +- [ ] `.env` dalam `.gitignore` +- [ ] `storage/app/private/` tidak accessible dari web +- [ ] Backup berjalan secara automatik +- [ ] Log rotation dikonfig +- [ ] Admin default password ditukar selepas login pertama +- [ ] Rate limiting aktif +- [ ] Queue worker berjalan +- [ ] Scheduler berjalan +- [ ] Test upload audio berjaya +- [ ] Test transcription berjaya +- [ ] Test authorization (admin tidak dapat akses kandungan) lulus diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ffd855 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +FROM php:8.4-fpm + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + libpng-dev \ + libonig-dev \ + libxml2-dev \ + libzip-dev \ + zip \ + unzip \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Install PHP extensions +RUN docker-php-ext-install \ + pdo_mysql \ + mbstring \ + exif \ + pcntl \ + bcmath \ + gd \ + zip \ + opcache + +# Install Redis extension +RUN pecl install redis && docker-php-ext-enable redis + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /var/www/html + +# Copy application files +COPY . . + +# Install PHP dependencies (production only) +RUN composer install --optimize-autoloader --no-dev --no-interaction + +# Set permissions +RUN chown -R www-data:www-data /var/www/html \ + && chmod -R 755 /var/www/html/storage \ + && chmod -R 755 /var/www/html/bootstrap/cache + +# Copy custom php.ini +COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad1377 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/SECURITY_MODEL.md b/SECURITY_MODEL.md new file mode 100644 index 0000000..0c304f5 --- /dev/null +++ b/SECURITY_MODEL.md @@ -0,0 +1,277 @@ +# SECURITY_MODEL.md — Speech2Text MBIP + +## Prinsip Asas Keselamatan + +Sistem ini direka untuk menyimpan data sensitif kerajaan/PBT. Prinsip berikut mesti dipatuhi di setiap lapisan: + +1. **Privacy by Design** — Admin ialah pentadbir akaun, bukan pentadbir kandungan. +2. **Defense in Depth** — Authorization dikuatkuasakan di Policy, Controller, Query, dan UI. +3. **Least Privilege** — Setiap role hanya mendapat akses minimum yang diperlukan. +4. **Audit Everything** — Semua tindakan sensitif direkodkan dengan lengkap. +5. **No Cloud Egress** — Data tidak keluar dari Docker network. + +--- + +## Model Kebenaran (Authorization) + +### Admin — APA yang BOLEH dan TIDAK BOLEH + +| Tindakan | Admin | +|---|---| +| Daftar pengguna baru | ✓ | +| Lihat senarai pengguna | ✓ | +| Activate/deactivate pengguna | ✓ | +| Delete pengguna (belum guna) | ✓ | +| Tukar emel pengguna | ✓ | +| Lihat statistik penggunaan (tanpa kandungan) | ✓ | +| Lihat metadata projek (tajuk, owner, status, saiz, tarikh) | ✓ | +| Transfer ownership projek (dengan justifikasi) | ✓ | +| Lihat audit log pentadbiran | ✓ | +| **Dengar / download audio** | ✗ | +| **Baca transcript** | ✗ | +| **Lihat komen dalam projek** | ✗ | +| **Masuk halaman detail projek (kandungan)** | ✗ | +| **Edit transcript** | ✗ | + +### Owner (Pengguna Jabatan — pemilik projek) + +| Tindakan | Owner | +|---|---| +| Cipta projek | ✓ | +| Upload audio | ✓ | +| Lihat / dengar audio sendiri | ✓ | +| Lihat / edit transcript sendiri | ✓ | +| Tambah / buang collaborator | ✓ | +| Delete projek / audio / teks | ✓ | +| Retry transcription | ✓ | +| Lihat versi history | ✓ | +| Restore versi lama | ✓ | +| Buat komen | ✓ | + +### Collaborator (Pengguna yang dikongsi projek) + +| Tindakan | Collaborator | +|---|---| +| Lihat projek yang dikongsi | ✓ | +| Dengar audio asal | ✓ | +| Lihat / edit transcript | ✓ | +| Buat komen | ✓ | +| Lihat versi history | ✓ | +| Restore versi lama | ✓ | +| **Delete projek / audio / teks** | ✗ | +| **Transfer ownership** | ✗ | +| **Urus collaborator lain** | ✗ | + +--- + +## Laravel Policies + +### `TranscriptionProjectPolicy` + +```php +// Setiap method semak sama ada user adalah owner ATAU collaborator +// Admin sengaja TIDAK diberikan akses ke content + +view() → owner ATAU collaborator (bukan admin) +create() → user aktif (bukan admin) +update() → owner ATAU collaborator +delete() → owner sahaja +viewAudio() → owner ATAU collaborator +downloadAudio()→ owner ATAU collaborator +retryTranscription() → owner sahaja +manageCollaborators() → owner sahaja +transferOwner() → admin sahaja (metadata action, bukan content) +viewMetadata() → admin ATAU owner (metadata minimum sahaja untuk admin) +``` + +### `CommentPolicy` + +```php +view() → owner ATAU collaborator (bukan admin) +create() → owner ATAU collaborator +delete() → owner komen sendiri ATAU owner projek +``` + +### `TranscriptVersionPolicy` + +```php +view() → owner ATAU collaborator +restore() → owner ATAU collaborator +``` + +### `UserPolicy` + +```php +create() → admin sahaja +update() → admin sahaja +delete() → admin sahaja (dengan semak tiada usage) +activate() → admin sahaja +deactivate() → admin sahaja +changeEmail()→ admin sahaja +``` + +--- + +## Lapisan Keselamatan + +### L1: Authentication +- Laravel Breeze/custom auth dengan session +- `is_active` check di middleware `EnsureUserIsActive` +- Rate limit login: 5 percubaan per minit per IP +- Session expire: dikonfig dalam `.env` +- CSRF protection aktif untuk semua form + +### L2: Authorization (Policy) +- Setiap controller action wajib panggil `$this->authorize()` +- Jangan rely pada UI hide/show sahaja +- Gunakan `abort(403)` jika policy gagal + +### L3: Input Validation +- Semua input melalui Form Request class +- File upload: validate MIME type dari magic bytes (bukan extension sahaja) +- Allowed audio types: `audio/mpeg`, `audio/wav`, `audio/mp4`, `audio/x-m4a`, `video/mp4` +- Max file size: dikonfig `PRIVATE_AUDIO_MAX_MB` dalam `.env` +- Rate limit upload: 10 upload per jam per user + +### L4: Storage Security +- Fail audio **tidak** disimpan dalam `public/` atau `storage/app/public/` +- Path simpan: `storage/app/private/transcriptions/{project_uuid}/audio/{filename}` +- Semua akses audio melalui controller yang semak policy +- Guna `Storage::disk('private')->get()` untuk stream audio +- Response header `Content-Disposition: inline` untuk audio player +- Response header `X-Content-Type-Options: nosniff` + +### L5: IDOR Prevention +- Gunakan UUID dalam URL projek, bukan integer ID +- Route: `/projects/{project:uuid}` bukan `/projects/{id}` +- Policy semak ownership/collaborator, bukan hanya URL +- Semua query pakai `where('owner_user_id', auth()->id())` atau policy scope + +### L6: Output Sanitization +- Escape semua output Blade dengan `{{ }}` (bukan `{!! !!}`) +- Sanitize comment sebelum display dengan `htmlspecialchars()` +- Transcript display menggunakan `{{ }}` escape +- Content-Security-Policy header via middleware + +### L7: Audit Trail +- Semua tindakan sensitif log ke `audit_logs` +- Log IP address dan user agent +- Log nilai lama dan nilai baru (kecuali transcript content) +- Audit log adalah append-only (tiada update/delete pada audit_logs) + +### L8: Admin Content Isolation +```php +// Middleware atau scope yang enforce admin tidak dapat content +class AdminProjectScope { + public function apply($builder, $model) { + if (auth()->user()->role === 'admin') { + // Admin hanya dapat metadata sahaja + $builder->select([ + 'id', 'uuid', 'title', 'owner_user_id', + 'transcription_status', 'created_at', + 'file_size', 'duration_seconds', 'deleted_at' + // TIADA transcript_text, stored_audio_path + ]); + } + } +} +``` + +--- + +## Keselamatan Fail Audio + +### Upload Flow +``` +1. Validate MIME type (magic bytes, bukan extension) +2. Generate nama fail baru yang random (jangan guna nama asal) +3. Simpan ke path private: transcriptions/{uuid}/audio/{random_name}.{ext} +4. Simpan nama asal dalam database (original_filename) +5. Jangan simpan path dalam session atau cookie +``` + +### Serve/Stream Audio +```php +// AudioController@stream +public function stream(TranscriptionProject $project) +{ + $this->authorize('viewAudio', $project); + + $path = $project->stored_audio_path; + + // Verify file exists + abort_unless(Storage::disk('private')->exists($path), 404); + + return response()->stream(function () use ($path) { + $stream = Storage::disk('private')->readStream($path); + fpassthru($stream); + }, 200, [ + 'Content-Type' => $project->mime_type, + 'Content-Length' => Storage::disk('private')->size($path), + 'Content-Disposition' => 'inline', + 'Cache-Control' => 'no-store, no-cache, private', + 'X-Content-Type-Options' => 'nosniff', + ]); +} +``` + +--- + +## Konfigurasi Security Header + +```php +// middleware/SecurityHeaders.php +'X-Frame-Options' => 'DENY', +'X-XSS-Protection' => '1; mode=block', +'X-Content-Type-Options' => 'nosniff', +'Referrer-Policy' => 'strict-origin-when-cross-origin', +'Content-Security-Policy' => "default-src 'self'; ..." +``` + +--- + +## Enkripsi Data Sensitif + +### Transcript Text Encryption +Gunakan Laravel `encrypted` cast untuk `transcript_text`: +```php +// Model TranscriptionProject +protected $casts = [ + 'transcript_text' => 'encrypted', +]; +``` +- Data dienkripsi menggunakan `APP_KEY` sebelum simpan dalam database. +- Decrypt berlaku secara automatik apabila diakses melalui Eloquent. +- Tanpa `APP_KEY`, data tidak boleh dibaca walaupun database dicuri. +- **Penting:** Simpan `APP_KEY` dalam `.env` dan jangan commit ke git. + +--- + +## Semakan Keselamatan Deployment + +Sebelum production, semak: + +- [ ] `APP_DEBUG=false` +- [ ] `APP_ENV=production` +- [ ] `.env` tidak dalam git repository (ada dalam `.gitignore`) +- [ ] `APP_KEY` telah dijanakan (`php artisan key:generate`) +- [ ] Database password kuat dan berbeza dari default +- [ ] `storage/app/private/` tidak accessible dari web +- [ ] Nginx tidak serve `storage/` secara direct +- [ ] HTTPS/TLS aktif +- [ ] Rate limiting dikonfig +- [ ] Fail upload di luar `public_html` +- [ ] `storage:link` hanya untuk assets (bukan audio/transcript) +- [ ] Queue worker berjalan dengan user yang restricted +- [ ] Transcription worker tidak accessible dari internet (internal only) +- [ ] Ollama tidak accessible dari internet (internal only) + +--- + +## Nota untuk Pentadbir Sistem + +1. **Backup `APP_KEY`** — Jika `APP_KEY` hilang, semua `transcript_text` yang dienkripsi tidak boleh dibaca. +2. **Backup database secara berkala** — Termasuk `transcript_versions` yang menyimpan history. +3. **Log rotation** — Pastikan `storage/logs/` dirotate supaya tidak penuh. +4. **Retention policy** — Tentukan berapa lama fail audio disimpan selepas projek deleted. +5. **Monitor disk space** — Audio files boleh membesar. Tetapkan alert. diff --git a/TASK_PLAN.md b/TASK_PLAN.md new file mode 100644 index 0000000..ceddbc7 --- /dev/null +++ b/TASK_PLAN.md @@ -0,0 +1,342 @@ +# TASK_PLAN.md — Speech2Text MBIP + +## Ringkasan Fasa + +| Fasa | Fokus | Anggaran | +|---|---|---| +| Fasa 1 | Auth, Role, User Management, Docker | Asas wajib | +| Fasa 2 | Project CRUD, Upload Audio, Collaborator, Policies | Storage + RBAC | +| Fasa 3 | Queue, Transcription Worker, Status, Retry | AI Integration | +| Fasa 4 | Transcript Editor, Version History, Comments | Content Features | +| Fasa 5 | Admin Dashboard, Audit Log, Transfer Ownership | Admin Tools | +| Fasa 6 | Security Hardening, Tests, README, Production Checklist | Quality | + +--- + +## FASA 1: Asas — Auth, Role, User Management, Docker + +### Objektif +Sistem boleh login, admin boleh urus pengguna, Docker berjalan. + +### Checklist Fasa 1 + +#### 1.1 Struktur Projek Laravel +- [ ] `laravel new speech2text-mbip` dengan PHP 8.3+ +- [ ] Konfigurasi `.env.example` +- [ ] Setup `config/filesystems.php` — tambah private disk +- [ ] Setup `config/auth.php` +- [ ] Konfigurasi Laravel untuk UUID + +#### 1.2 Docker Setup +- [ ] `Dockerfile` untuk Laravel app (php:8.3-fpm) +- [ ] `docker-compose.yml` dengan services: app, nginx, mysql, redis, queue-worker, scheduler +- [ ] `docker/nginx/default.conf` +- [ ] `docker/php/php.ini` +- [ ] `.dockerignore` +- [ ] Test: `docker compose up -d --build` + +#### 1.3 Database — Migration Awal +- [ ] Migration: `departments` +- [ ] Migration: `users` (dengan `role`, `is_active`, `department_id`, `deleted_at`) +- [ ] Migration: `audit_logs` +- [ ] Migration: `sessions` (jika guna database session) + +#### 1.4 Models +- [ ] `User` model (dengan SoftDeletes, role check methods) +- [ ] `Department` model +- [ ] `AuditLog` model + +#### 1.5 Authentication +- [ ] Login form (Bootstrap 5) +- [ ] Logout +- [ ] Middleware `EnsureUserIsActive` — check `is_active` selepas login +- [ ] Rate limit login (5 attempts/minute) +- [ ] Redirect admin ke `/admin/dashboard`, user ke `/dashboard` + +#### 1.6 Role Middleware +- [ ] Middleware `EnsureAdmin` — untuk route `/admin/*` +- [ ] Middleware `EnsureUser` — untuk route `/dashboard/*`, `/projects/*` +- [ ] Register middleware dalam `bootstrap/app.php` + +#### 1.7 Admin — User Management +- [ ] `UserPolicy` — CRUD user oleh admin sahaja +- [ ] `UserController` (Admin) dengan actions: + - `index` — senarai semua pengguna + - `create` / `store` — daftar pengguna baru + - `edit` / `update` — tukar emel pengguna + - `activate` — aktifkan pengguna + - `deactivate` — deactivate pengguna + - `destroy` — delete pengguna (hanya jika tiada usage) +- [ ] Form Request: `CreateUserRequest`, `UpdateUserEmailRequest` +- [ ] Views (Bootstrap 5): senarai pengguna, form cipta, form edit emel +- [ ] Action classes: `CreateUserAction`, `DeactivateUserAction`, `ActivateUserAction`, `DeleteUserAction`, `ChangeUserEmailAction` + +#### 1.8 Audit Log Service +- [ ] `AuditLogService` dengan method `log()` +- [ ] Panggil AuditLogService dalam setiap action yang relevan +- [ ] Rekodkan: actor, action, target, old_values, new_values, ip, user_agent, justification + +#### 1.9 Seeder +- [ ] `DepartmentSeeder` — beberapa jabatan contoh +- [ ] `AdminUserSeeder` — admin dari `.env` variables +- [ ] `DatabaseSeeder` — panggil semua seeders + +#### 1.10 Admin Dashboard (Asas) +- [ ] Dashboard route `/admin/dashboard` +- [ ] Layout admin (Bootstrap 5 sidebar) +- [ ] Papar: jumlah pengguna aktif, jumlah pengguna deactive + +### Arahan Test Fasa 1 + +```bash +# Build dan jalankan +cd /svr/speech2text +docker compose up -d --build + +# Setup +docker compose exec app php artisan key:generate +docker compose exec app php artisan migrate --seed + +# Semak +docker compose exec app php artisan migrate:status +docker compose exec app php artisan route:list + +# Test manual: +# 1. Buka browser http://localhost +# 2. Login sebagai admin (dari .env) +# 3. Admin redirect ke /admin/dashboard +# 4. Admin boleh lihat senarai pengguna +# 5. Admin boleh daftar pengguna baru +# 6. Admin boleh deactivate pengguna +# 7. Pengguna deactivated tidak boleh login +# 8. Admin boleh activate semula +# 9. Admin boleh tukar emel +# 10. Semak audit_logs ada rekod untuk setiap tindakan +# 11. Login sebagai pengguna biasa -> redirect /dashboard +# 12. Pengguna biasa tidak boleh akses /admin/* +``` + +--- + +## FASA 2: Project CRUD, Upload Audio, Collaborator, Policies + +### Checklist Fasa 2 + +#### 2.1 Migration +- [ ] `transcription_projects` +- [ ] `project_collaborators` + +#### 2.2 Models & Relationships +- [ ] `TranscriptionProject` (dengan UUID, SoftDeletes) +- [ ] `ProjectCollaborator` +- [ ] Relationships dalam `User` model + +#### 2.3 Policies +- [ ] `TranscriptionProjectPolicy` — view, create, update, delete, viewAudio, manageCollaborators +- [ ] Register policies dalam `AuthServiceProvider` + +#### 2.4 File Upload +- [ ] Konfigurasi private disk dalam `config/filesystems.php` +- [ ] Form Request: `UploadAudioRequest` (validate MIME, size) +- [ ] `StorageService` — handle private file storage +- [ ] AudioController — stream audio melalui policy check + +#### 2.5 Project Controllers +- [ ] `ProjectController` (User) — CRUD projek +- [ ] `AudioController` (User) — stream/download audio +- [ ] `CollaboratorController` (User) — tambah/buang collaborator + +#### 2.6 Views +- [ ] Project list (dashboard) +- [ ] Create project + upload audio form +- [ ] Project detail (placeholder transcript area) +- [ ] Collaborator management panel + +### Arahan Test Fasa 2 + +```bash +# Test manual: +# 1. Login sebagai user biasa +# 2. Cipta projek baru dengan upload audio +# 3. Semak fail ada dalam storage/app/private/ (bukan public/) +# 4. Dengar audio dalam browser (via controller stream) +# 5. Tambah collaborator +# 6. Login sebagai collaborator — boleh lihat projek +# 7. Login sebagai user lain (bukan collaborator) — 403 +# 8. Login sebagai admin — tidak boleh dengar audio (403) +# 9. Admin hanya boleh lihat metadata projek +``` + +--- + +## FASA 3: Queue, Transcription Worker, Status, Retry + +### Checklist Fasa 3 + +#### 3.1 Transcription Worker (Python) +- [ ] `docker/transcription-worker/Dockerfile` (Python 3.11 + faster-whisper) +- [ ] `docker/transcription-worker/main.py` (FastAPI endpoint `/transcribe`) +- [ ] `/health` endpoint untuk monitoring +- [ ] Tambah service ke `docker-compose.yml` + +#### 3.2 Laravel Queue Job +- [ ] `TranscribeAudioJob` — baca audio, hantar ke Python worker, simpan result +- [ ] Handle timeout dan retry +- [ ] Update `transcription_status` di database + +#### 3.3 Ollama Integration (Optional) +- [ ] `OllamaService` — panggil Ollama API untuk post-processing +- [ ] `OllamaPostProcessJob` — dispatch selepas transcription selesai +- [ ] Config via `.env`: `OLLAMA_ENABLED`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL` + +#### 3.4 UI Status +- [ ] Status badge dalam project detail (pending/processing/completed/failed) +- [ ] Auto-refresh status menggunakan polling atau SSE +- [ ] Retry button untuk failed transcription (owner sahaja) + +### Arahan Test Fasa 3 + +```bash +# Semak transcription worker +docker compose logs -f transcription-worker +docker compose exec app curl http://transcription-worker:8000/health + +# Upload audio dan monitor +docker compose logs -f queue-worker +docker compose exec app php artisan queue:work --once + +# Semak result dalam database +docker compose exec mysql mysql -u root -p speech2text \ + -e "SELECT id, title, transcription_status, transcript_text FROM transcription_projects LIMIT 5;" +``` + +--- + +## FASA 4: Transcript Editor, Version History, Comments + +### Checklist Fasa 4 + +#### 4.1 Migration +- [ ] `transcript_versions` +- [ ] `project_comments` + +#### 4.2 Models +- [ ] `TranscriptVersion` +- [ ] `ProjectComment` (dengan SoftDeletes) + +#### 4.3 Policies +- [ ] `TranscriptVersionPolicy` +- [ ] `CommentPolicy` + +#### 4.4 Controllers & Actions +- [ ] `TranscriptController` — view dan update transcript +- [ ] `TranscriptVersionController` — lihat history, restore +- [ ] `CommentController` — CRUD komen + +#### 4.5 Views +- [ ] Transcript editor (textarea, save button) +- [ ] Version history timeline +- [ ] Restore version button +- [ ] Comment/chat panel (sidebar atau bawah transcript) + +### Arahan Test Fasa 4 + +```bash +# Test: +# 1. Edit transcript — semak transcript_versions ada rekod baru +# 2. Edit kedua kali — semak version_number bertambah +# 3. Restore version lama — semak transcript_text dikemaskini +# 4. Collaborator boleh edit transcript +# 5. Admin tidak boleh akses transcript editor (403) +# 6. Buat komen +# 7. Admin tidak boleh lihat komen (403) +``` + +--- + +## FASA 5: Admin Dashboard, Audit Log, Transfer Ownership + +### Checklist Fasa 5 + +#### 5.1 Admin Dashboard Lengkap +- [ ] Statistik pengguna (aktif/deactivate) +- [ ] Statistik projek (pending/processing/completed/failed) +- [ ] Statistik storage +- [ ] Top users by usage +- [ ] Chart harian/bulanan (jQuery chart library) + +#### 5.2 Admin Project Metadata List +- [ ] Senarai projek dengan metadata sahaja (TANPA transcript_text) +- [ ] Filter: status, tarikh, owner +- [ ] Pagination + +#### 5.3 Transfer Ownership +- [ ] Form transfer ownership dengan justification field +- [ ] `TransferProjectOwnerAction` +- [ ] Option: jadikan owner lama sebagai collaborator +- [ ] Audit log untuk transfer + +#### 5.4 Audit Log Browser +- [ ] Senarai audit log dengan filter +- [ ] Filter: action, actor, tarikh, projek +- [ ] Pagination +- [ ] Export CSV (admin sahaja) + +### Arahan Test Fasa 5 + +```bash +# Test: +# 1. Admin lihat dashboard dengan statistik +# 2. Admin transfer ownership — semak audit log ada justifikasi +# 3. Selepas transfer, owner baru boleh akses projek +# 4. Owner lama (jika dijadikan collaborator) boleh akses +# 5. Admin browse audit log — semak semua rekod ada +``` + +--- + +## FASA 6: Security Hardening, Tests, README, Production + +### Checklist Fasa 6 + +#### 6.1 Feature Tests +- [ ] `AdminCannotViewTranscriptTest` +- [ ] `AdminCannotDownloadAudioTest` +- [ ] `OwnerCanAccessOwnProjectTest` +- [ ] `CollaboratorCanAccessSharedProjectTest` +- [ ] `NonCollaboratorCannotAccessProjectTest` +- [ ] `OnlyOwnerCanDeleteProjectTest` +- [ ] `CollaboratorCanEditTranscriptTest` +- [ ] `TranscriptEditCreatesVersionHistoryTest` +- [ ] `AdminTransferOwnershipRequiresJustificationTest` +- [ ] `DeactivateUserPreventsLoginTest` +- [ ] `DeleteUnusedUserOnlyAllowedTest` +- [ ] `EmailChangeCreatesAuditLogTest` + +#### 6.2 Security Hardening +- [ ] Security headers middleware +- [ ] Rate limiting semua endpoint sensitif +- [ ] Validate MIME type dari magic bytes +- [ ] Semak semua `{{ }}` dalam Blade (bukan `{!! !!}`) +- [ ] Semak CSRF token semua form +- [ ] Remove debug routes + +#### 6.3 Production Checklist +- [ ] Semua item dalam `DEPLOYMENT.md` production checklist +- [ ] `APP_DEBUG=false` di production +- [ ] Config cache, route cache, view cache +- [ ] Queue supervisor setup + +#### 6.4 Documentation +- [ ] README.md utama +- [ ] Arahan deployment +- [ ] Arahan development local + +--- + +## Peringatan Penting + +> **Prinsip Privasi:** +> Admin = pentadbir AKAUN, bukan pentadbir KANDUNGAN. +> Setiap fasa mesti pastikan admin tidak boleh akses audio, transcript, atau komen. +> Semak dengan feature test sebelum anggap selesai. diff --git a/app/Actions/ActivateUserAction.php b/app/Actions/ActivateUserAction.php new file mode 100644 index 0000000..6db6a99 --- /dev/null +++ b/app/Actions/ActivateUserAction.php @@ -0,0 +1,24 @@ +update(['is_active' => true]); + + $this->audit->log('user_reactivated', [ + 'subject_type' => User::class, + 'subject_id' => $user->id, + 'target_user_id' => $user->id, + 'old_values' => ['is_active' => false], + 'new_values' => ['is_active' => true], + ]); + } +} diff --git a/app/Actions/ChangeUserEmailAction.php b/app/Actions/ChangeUserEmailAction.php new file mode 100644 index 0000000..5a9fb8a --- /dev/null +++ b/app/Actions/ChangeUserEmailAction.php @@ -0,0 +1,30 @@ +email; + + $user->update([ + 'email' => $newEmail, + 'email_verified_at' => null, + ]); + + $this->audit->log('user_email_changed', [ + 'subject_type' => User::class, + 'subject_id' => $user->id, + 'target_user_id' => $user->id, + 'old_values' => ['email' => $oldEmail], + 'new_values' => ['email' => $newEmail], + 'justification' => $justification, + ]); + } +} diff --git a/app/Actions/CreateUserAction.php b/app/Actions/CreateUserAction.php new file mode 100644 index 0000000..4537a76 --- /dev/null +++ b/app/Actions/CreateUserAction.php @@ -0,0 +1,37 @@ + $data['name'], + 'email' => $data['email'], + 'password' => $data['password'], + 'role' => $data['role'] ?? 'user', + 'department_id' => $data['department_id'] ?? null, + 'is_active' => true, + ]); + + $this->audit->log('user_created', [ + 'subject_type' => User::class, + 'subject_id' => $user->id, + 'target_user_id' => $user->id, + 'new_values' => [ + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role, + 'department_id' => $user->department_id, + ], + ]); + + return $user; + } +} diff --git a/app/Actions/DeactivateUserAction.php b/app/Actions/DeactivateUserAction.php new file mode 100644 index 0000000..5b52be3 --- /dev/null +++ b/app/Actions/DeactivateUserAction.php @@ -0,0 +1,28 @@ +update(['is_active' => false]); + + // Invalidate all sessions for this user + \DB::table('sessions')->where('user_id', $user->id)->delete(); + + $this->audit->log('user_deactivated', [ + 'subject_type' => User::class, + 'subject_id' => $user->id, + 'target_user_id' => $user->id, + 'old_values' => ['is_active' => true], + 'new_values' => ['is_active' => false], + 'justification' => $justification, + ]); + } +} diff --git a/app/Actions/DeleteUserAction.php b/app/Actions/DeleteUserAction.php new file mode 100644 index 0000000..7e6e05c --- /dev/null +++ b/app/Actions/DeleteUserAction.php @@ -0,0 +1,37 @@ +hasUsage()) { + throw ValidationException::withMessages([ + 'user' => 'Pengguna ini tidak boleh dipadam kerana telah menggunakan sistem. Gunakan deactivate.', + ]); + } + + $userData = [ + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role, + ]; + + $this->audit->log('user_deleted', [ + 'subject_type' => User::class, + 'subject_id' => $user->id, + 'target_user_id' => $user->id, + 'old_values' => $userData, + 'justification' => $justification, + ]); + + $user->forceDelete(); + } +} diff --git a/app/Actions/TransferOwnershipAction.php b/app/Actions/TransferOwnershipAction.php new file mode 100644 index 0000000..97eecd9 --- /dev/null +++ b/app/Actions/TransferOwnershipAction.php @@ -0,0 +1,35 @@ +owner; + + $project->update(['owner_user_id' => $newOwner->id]); + + $this->audit->log('project_ownership_transferred', [ + 'subject_type' => TranscriptionProject::class, + 'subject_id' => $project->id, + 'project_id' => $project->id, + 'target_user_id' => $newOwner->id, + 'old_values' => [ + 'owner_user_id' => $oldOwner?->id, + 'owner_name' => $oldOwner?->name, + ], + 'new_values' => [ + 'owner_user_id' => $newOwner->id, + 'owner_name' => $newOwner->name, + ], + 'justification' => $justification, + ]); + } +} diff --git a/app/Http/Controllers/Admin/AuditLogController.php b/app/Http/Controllers/Admin/AuditLogController.php new file mode 100644 index 0000000..fb1dc53 --- /dev/null +++ b/app/Http/Controllers/Admin/AuditLogController.php @@ -0,0 +1,89 @@ +when($request->action, fn ($q, $a) => $q->where('action', $a)) + ->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id)) + ->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id)) + ->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d)) + ->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d)) + ->when($request->search, fn ($q, $s) => + $q->where('action', 'like', "%{$s}%") + ->orWhere('justification', 'like', "%{$s}%") + ->orWhere('ip_address', 'like', "%{$s}%") + ) + ->orderByDesc('created_at') + ->paginate(30) + ->withQueryString(); + + $actions = AuditLog::select('action')->distinct()->pluck('action')->sort()->values(); + $admins = User::where('role', 'admin')->orderBy('name')->get(['id', 'name']); + + return view('admin.audit-logs.index', compact('logs', 'actions', 'admins')); + } + + public function export(Request $request): StreamedResponse + { + $query = AuditLog::with(['actor', 'targetUser']) + ->when($request->action, fn ($q, $a) => $q->where('action', $a)) + ->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id)) + ->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id)) + ->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d)) + ->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d)) + ->when($request->search, fn ($q, $s) => + $q->where('action', 'like', "%{$s}%") + ->orWhere('justification', 'like', "%{$s}%") + ->orWhere('ip_address', 'like', "%{$s}%") + ) + ->orderByDesc('created_at'); + + $filename = 'audit-log-' . now()->format('Ymd-His') . '.csv'; + + return response()->streamDownload(function () use ($query) { + $handle = fopen('php://output', 'w'); + + // UTF-8 BOM untuk Excel + fwrite($handle, "\xEF\xBB\xBF"); + + fputcsv($handle, [ + 'Masa', 'Oleh', 'Peranan', 'Tindakan', + 'Sasaran', 'Projek ID', 'IP', 'Justifikasi', + ]); + + $query->chunk(500, function ($logs) use ($handle) { + foreach ($logs as $log) { + fputcsv($handle, [ + $log->created_at?->format('d/m/Y H:i:s'), + $log->actor?->name ?? 'Sistem', + $log->actor_role ?? '', + $log->action, + $log->targetUser?->name ?? ($log->subject_type + ? class_basename($log->subject_type) . '#' . $log->subject_id + : ''), + $log->project_id ?? '', + $log->ip_address ?? '', + $log->justification ?? '', + ]); + } + }); + + fclose($handle); + }, $filename, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => "attachment; filename=\"{$filename}\"", + ]); + } +} diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php new file mode 100644 index 0000000..e17c4b1 --- /dev/null +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -0,0 +1,65 @@ + User::where('is_active', true)->where('role', 'user')->count(), + 'users_inactive' => User::where('is_active', false)->where('role', 'user')->count(), + 'users_total' => User::where('role', 'user')->count(), + 'projects_total' => TranscriptionProject::count(), + 'projects_pending' => TranscriptionProject::where('transcription_status', 'pending')->count(), + 'projects_processing' => TranscriptionProject::where('transcription_status', 'processing')->count(), + 'projects_completed' => TranscriptionProject::where('transcription_status', 'completed')->count(), + 'projects_failed' => TranscriptionProject::where('transcription_status', 'failed')->count(), + 'projects_deleted' => TranscriptionProject::onlyTrashed()->count(), + 'projects_last_7' => TranscriptionProject::where('created_at', '>=', now()->subDays(7))->count(), + 'projects_last_30' => TranscriptionProject::where('created_at', '>=', now()->subDays(30))->count(), + 'storage_bytes' => (int) TranscriptionProject::sum('file_size'), + 'storage_completed_bytes' => (int) TranscriptionProject::where('transcription_status', 'completed')->sum('file_size'), + 'duration_total_seconds' => (int) TranscriptionProject::whereNotNull('duration_seconds')->sum('duration_seconds'), + ]; + + // Top 5 pengguna paling aktif (by project count) + $topUsers = User::where('role', 'user') + ->withCount('ownedProjects') + ->orderByDesc('owned_projects_count') + ->limit(5) + ->get(['id', 'name', 'email']); + + // Trend projek 30 hari lepas (group by date) + $trendRaw = TranscriptionProject::select( + DB::raw('DATE(created_at) as date'), + DB::raw('COUNT(*) as total'), + DB::raw('SUM(CASE WHEN transcription_status = "completed" THEN 1 ELSE 0 END) as completed'), + DB::raw('SUM(CASE WHEN transcription_status = "failed" THEN 1 ELSE 0 END) as failed') + ) + ->where('created_at', '>=', now()->subDays(29)->startOfDay()) + ->groupBy(DB::raw('DATE(created_at)')) + ->orderBy('date') + ->get(); + + // Projek mengikut jabatan (metadata sahaja) + $deptStats = Department::select('departments.name') + ->selectRaw('COUNT(tp.id) as project_count') + ->leftJoin('users', 'users.department_id', '=', 'departments.id') + ->leftJoin('transcription_projects as tp', 'tp.owner_user_id', '=', 'users.id') + ->where('departments.is_active', true) + ->groupBy('departments.id', 'departments.name') + ->orderByDesc('project_count') + ->limit(8) + ->get(); + + return view('admin.dashboard', compact('stats', 'topUsers', 'trendRaw', 'deptStats')); + } +} diff --git a/app/Http/Controllers/Admin/ProjectMetadataController.php b/app/Http/Controllers/Admin/ProjectMetadataController.php new file mode 100644 index 0000000..6ba863b --- /dev/null +++ b/app/Http/Controllers/Admin/ProjectMetadataController.php @@ -0,0 +1,65 @@ +with(['owner:id,name,email', 'projectCollaborators']) + ->select([ + 'id', 'uuid', 'title', 'owner_user_id', + 'transcription_status', 'transcription_engine', + 'file_size', 'duration_seconds', + 'language', + 'created_at', 'deleted_at', + // Sengaja TIDAK ambil: transcript_text, stored_audio_path + ]) + ->when($request->status, fn ($q, $s) => $q->where('transcription_status', $s)) + ->when($request->search, fn ($q, $s) => + $q->where('title', 'like', "%{$s}%") + ) + ->when($request->owner_id, fn ($q, $id) => $q->where('owner_user_id', $id)) + ->orderByDesc('created_at') + ->paginate(25) + ->withQueryString(); + + $owners = User::where('role', 'user') + ->where('is_active', true) + ->orderBy('name') + ->get(['id', 'name']); + + return view('admin.projects.index', compact('projects', 'owners')); + } + + public function transferOwner( + TransferOwnershipRequest $request, + TranscriptionProject $project, + TransferOwnershipAction $action + ): RedirectResponse { + $this->authorize('transferOwner', $project); + + $newOwner = User::findOrFail($request->new_owner_id); + + // Pastikan pemilik baru adalah pengguna aktif (bukan admin) + abort_if($newOwner->isAdmin(), 403, 'Admin tidak boleh menjadi pemilik projek.'); + abort_if(! $newOwner->isActive(), 422, 'Pengguna tidak aktif tidak boleh menerima pemindahan projek.'); + abort_if($project->owner_user_id === $newOwner->id, 422, 'Projek sudah dimiliki oleh pengguna ini.'); + + $action->execute($project, $newOwner, $request->justification); + + return redirect()->route('admin.projects.index') + ->with('success', "Pemilikan projek \"{$project->title}\" berjaya dipindahkan kepada {$newOwner->name}."); + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..3d0f6eb --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,106 @@ +authorize('viewAny', User::class); + + $users = User::with('department') + ->where('role', 'user') + ->when($request->search, fn ($q, $s) => + $q->where('name', 'like', "%{$s}%") + ->orWhere('email', 'like', "%{$s}%") + ) + ->when($request->status, fn ($q, $s) => + $q->where('is_active', $s === 'active') + ) + ->orderBy('name') + ->paginate(20) + ->withQueryString(); + + return view('admin.users.index', compact('users')); + } + + public function create(): View + { + $this->authorize('create', User::class); + + $departments = Department::where('is_active', true)->orderBy('name')->get(); + + return view('admin.users.create', compact('departments')); + } + + public function store(CreateUserRequest $request, CreateUserAction $action): RedirectResponse + { + $action->execute($request->validated()); + + return redirect()->route('admin.users.index') + ->with('success', 'Pengguna berjaya didaftarkan.'); + } + + public function edit(User $user): View + { + $this->authorize('changeEmail', $user); + + $departments = Department::where('is_active', true)->orderBy('name')->get(); + + return view('admin.users.edit', compact('user', 'departments')); + } + + public function updateEmail(UpdateUserEmailRequest $request, User $user, ChangeUserEmailAction $action): RedirectResponse + { + $this->authorize('changeEmail', $user); + + $action->execute($user, $request->email, $request->justification); + + return redirect()->route('admin.users.index') + ->with('success', "E-mel pengguna {$user->name} berjaya dikemaskini."); + } + + public function activate(Request $request, User $user, ActivateUserAction $action): RedirectResponse + { + $this->authorize('activate', $user); + + $action->execute($user); + + return redirect()->route('admin.users.index') + ->with('success', "Akaun {$user->name} berjaya diaktifkan."); + } + + public function deactivate(Request $request, User $user, DeactivateUserAction $action): RedirectResponse + { + $this->authorize('deactivate', $user); + + $action->execute($user, $request->justification); + + return redirect()->route('admin.users.index') + ->with('success', "Akaun {$user->name} berjaya dinyahaktifkan."); + } + + public function destroy(Request $request, User $user, DeleteUserAction $action): RedirectResponse + { + $this->authorize('delete', $user); + + $action->execute($user, $request->justification); + + return redirect()->route('admin.users.index') + ->with('success', "Pengguna {$user->name} berjaya dipadam."); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..a0afc7a --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,58 @@ +authenticate(); + + $request->session()->regenerate(); + + $user = Auth::user(); + + // Semak is_active selepas login berjaya + if (! $user->is_active) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return back()->withErrors([ + 'email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.', + ]); + } + + // Update last login + $user->update(['last_login_at' => now()]); + + // Redirect mengikut role + if ($user->isAdmin()) { + return redirect()->intended(route('admin.dashboard')); + } + + return redirect()->intended(route('user.dashboard')); + } + + public function destroy(Request $request): RedirectResponse + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e749914 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,13 @@ +authorize('viewAudio', $project); + + $path = $project->stored_audio_path; + + abort_unless($this->storage->exists($path), 404); + + $size = $this->storage->size($path); + $mimeType = $project->mime_type; + + return response()->stream(function () use ($path) { + $stream = $this->storage->readStream($path); + if ($stream) { + fpassthru($stream); + fclose($stream); + } + }, 200, [ + 'Content-Type' => $mimeType, + 'Content-Length' => $size, + 'Content-Disposition' => 'inline', + 'Cache-Control' => 'no-store, no-cache, private, must-revalidate', + 'X-Content-Type-Options' => 'nosniff', + 'Accept-Ranges' => 'bytes', + ]); + } +} diff --git a/app/Http/Controllers/User/CollaboratorController.php b/app/Http/Controllers/User/CollaboratorController.php new file mode 100644 index 0000000..f0862ec --- /dev/null +++ b/app/Http/Controllers/User/CollaboratorController.php @@ -0,0 +1,72 @@ +authorize('manageCollaborators', $project); + + $data = $request->validate([ + 'email' => ['required', 'email', 'exists:users,email'], + 'role' => ['nullable', 'in:editor,viewer'], + ]); + + $user = User::where('email', $data['email'])->where('is_active', true)->firstOrFail(); + + // Owner tidak boleh jadi collaborator + if ($project->isOwnedBy($user)) { + return back()->withErrors(['email' => 'Pengguna ini adalah pemilik projek.']); + } + + // Elak duplicate + $existing = ProjectCollaborator::where('project_id', $project->id) + ->where('user_id', $user->id) + ->exists(); + + if ($existing) { + return back()->withErrors(['email' => 'Pengguna ini sudah menjadi collaborator.']); + } + + ProjectCollaborator::create([ + 'project_id' => $project->id, + 'user_id' => $user->id, + 'role' => $data['role'] ?? 'editor', + 'added_by' => auth()->id(), + ]); + + $audit->log('collaborator_added', [ + 'project_id' => $project->id, + 'target_user_id' => $user->id, + 'new_values' => ['email' => $user->email, 'role' => $data['role'] ?? 'editor'], + ]); + + return back()->with('success', "{$user->name} berjaya ditambah sebagai collaborator."); + } + + public function destroy(TranscriptionProject $project, User $user, AuditLogService $audit): RedirectResponse + { + $this->authorize('manageCollaborators', $project); + + ProjectCollaborator::where('project_id', $project->id) + ->where('user_id', $user->id) + ->delete(); + + $audit->log('collaborator_removed', [ + 'project_id' => $project->id, + 'target_user_id' => $user->id, + 'old_values' => ['email' => $user->email], + ]); + + return back()->with('success', "{$user->name} telah dibuang daripada collaborator."); + } +} diff --git a/app/Http/Controllers/User/CommentController.php b/app/Http/Controllers/User/CommentController.php new file mode 100644 index 0000000..ce83b53 --- /dev/null +++ b/app/Http/Controllers/User/CommentController.php @@ -0,0 +1,52 @@ +authorize('create', [ProjectComment::class, $project]); + + $comment = $project->comments()->create([ + 'user_id' => auth()->id(), + 'message' => $request->message, + ]); + + $audit->log('comment_added', [ + 'project_id' => $project->id, + 'new_values' => ['comment_id' => $comment->id], + ]); + + return back()->with('success', 'Komen berjaya ditambah.'); + } + + public function destroy( + TranscriptionProject $project, + ProjectComment $comment, + AuditLogService $audit + ): RedirectResponse { + abort_if($comment->project_id !== $project->id, 404); + + $this->authorize('delete', $comment); + + $audit->log('comment_deleted', [ + 'project_id' => $project->id, + 'old_values' => ['comment_id' => $comment->id], + ]); + + $comment->delete(); + + return back()->with('success', 'Komen berjaya dipadam.'); + } +} diff --git a/app/Http/Controllers/User/DashboardController.php b/app/Http/Controllers/User/DashboardController.php new file mode 100644 index 0000000..662f115 --- /dev/null +++ b/app/Http/Controllers/User/DashboardController.php @@ -0,0 +1,26 @@ +id) + ->orderByDesc('created_at') + ->paginate(10, ['*'], 'owned'); + + $sharedProjects = $user->collaboratingProjects() + ->orderByDesc('transcription_projects.created_at') + ->paginate(10, ['*'], 'shared'); + + return view('user.dashboard', compact('ownedProjects', 'sharedProjects')); + } +} diff --git a/app/Http/Controllers/User/ProjectController.php b/app/Http/Controllers/User/ProjectController.php new file mode 100644 index 0000000..0a45860 --- /dev/null +++ b/app/Http/Controllers/User/ProjectController.php @@ -0,0 +1,122 @@ +authorize('create', TranscriptionProject::class); + + return view('user.projects.create'); + } + + public function store(CreateProjectRequest $request, StorageService $storage, AuditLogService $audit): RedirectResponse + { + $this->authorize('create', TranscriptionProject::class); + + $file = $request->file('audio'); + $uuid = (string) \Illuminate\Support\Str::uuid(); + $path = $storage->storeAudio($file, $uuid); + + $project = TranscriptionProject::create([ + 'uuid' => $uuid, + 'title' => $request->title, + 'description' => $request->description, + 'owner_user_id' => Auth::id(), + 'original_filename' => $file->getClientOriginalName(), + 'stored_audio_path' => $path, + 'mime_type' => $file->getMimeType(), + 'file_size' => $file->getSize(), + 'language' => $request->language ?? 'ms', + 'transcription_status' => 'pending', + 'transcription_engine' => config('speech2text.transcription.engine'), + ]); + + $audit->log('project_created', [ + 'project_id' => $project->id, + 'new_values' => ['title' => $project->title, 'uuid' => $project->uuid], + ]); + + $audit->log('audio_uploaded', [ + 'project_id' => $project->id, + 'new_values' => [ + 'original_filename' => $project->original_filename, + 'file_size' => $project->file_size, + 'mime_type' => $project->mime_type, + ], + ]); + + TranscribeAudioJob::dispatch($project); + + return redirect()->route('user.projects.show', $project) + ->with('success', 'Projek berjaya dicipta. Audio sedang dalam barisan untuk ditranskripkan.'); + } + + public function show(TranscriptionProject $project): View + { + $this->authorize('view', $project); + + $project->load(['owner', 'collaborators.department', 'transcriptVersions.editor', 'comments.user']); + + return view('user.projects.show', compact('project')); + } + + // Endpoint polling status — JSON, tidak mendedahkan transcript + public function status(TranscriptionProject $project): JsonResponse + { + $this->authorize('view', $project); + + return response()->json([ + 'status' => $project->transcription_status, + 'duration_seconds' => $project->duration_seconds, + 'processed_at' => $project->processed_at?->format('d/m/Y H:i'), + 'error_message' => $project->isFailed() ? $project->error_message : null, + ]); + } + + public function retry(TranscriptionProject $project, AuditLogService $audit): RedirectResponse + { + $this->authorize('retryTranscription', $project); + + $project->update([ + 'transcription_status' => 'pending', + 'error_message' => null, + ]); + + $audit->log('transcription_started', [ + 'project_id' => $project->id, + 'new_values' => ['retry' => true], + ]); + + TranscribeAudioJob::dispatch($project); + + return back()->with('success', 'Transkripsi sedang dicuba semula.'); + } + + public function destroy(TranscriptionProject $project, AuditLogService $audit): RedirectResponse + { + $this->authorize('delete', $project); + + $audit->log('project_deleted', [ + 'project_id' => $project->id, + 'old_values' => ['title' => $project->title, 'uuid' => $project->uuid], + ]); + + $project->delete(); + + return redirect()->route('user.dashboard') + ->with('success', 'Projek berjaya dipadam.'); + } +} diff --git a/app/Http/Controllers/User/TranscriptController.php b/app/Http/Controllers/User/TranscriptController.php new file mode 100644 index 0000000..82734dd --- /dev/null +++ b/app/Http/Controllers/User/TranscriptController.php @@ -0,0 +1,112 @@ +authorize('editTranscript', $project); + + $oldText = $project->transcript_text; + + $nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1; + + TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => Auth::id(), + 'version_number' => $nextVersion, + 'old_text' => $oldText, + 'new_text' => $request->transcript_text, + 'change_summary' => $request->change_summary ?? 'Dikemaskini oleh pengguna', + 'created_at' => now(), + ]); + + $project->update([ + 'transcript_text' => $request->transcript_text, + 'transcription_status' => 'completed', + ]); + + $audit->log('transcript_edited', [ + 'project_id' => $project->id, + 'new_values' => ['version_number' => $nextVersion], + ]); + + return back()->with('success', 'Transkripsi berjaya disimpan.'); + } + + /** + * Muat naik fail transkripsi .txt dari luar sistem. + */ + public function uploadExternal( + UploadExternalTranscriptRequest $request, + TranscriptionProject $project, + AuditLogService $audit + ): RedirectResponse { + $this->authorize('editTranscript', $project); + + $text = file_get_contents($request->file('transcript_file')->getRealPath()); + + // Bersihkan BOM dan normalkan line endings + $text = preg_replace('/^\xef\xbb\xbf/', '', $text); + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = trim($text); + + if ($text === '') { + return back()->withErrors(['transcript_file' => 'Fail transkripsi kosong.']); + } + + $oldText = $project->transcript_text; + $nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1; + + TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => Auth::id(), + 'version_number' => $nextVersion, + 'old_text' => $oldText, + 'new_text' => $text, + 'change_summary' => 'Transkripsi dimuat naik secara manual', + 'created_at' => now(), + ]); + + $project->update([ + 'transcript_text' => $text, + 'transcription_status' => 'completed', + 'transcription_engine' => 'manual', + 'processed_at' => $project->processed_at ?? now(), + ]); + + $audit->log('transcript_uploaded_external', [ + 'project_id' => $project->id, + 'new_values' => ['version_number' => $nextVersion], + ]); + + // Hantar ke Ollama jika pengguna minta dan Ollama dikonfigurasi + if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) { + OllamaPostProcessJob::dispatch($project); + } + + $msg = 'Transkripsi berjaya dimuat naik.'; + if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) { + $msg .= ' Pembersihan Ollama sedang diproses.'; + } + + return back()->with('success', $msg); + } +} diff --git a/app/Http/Controllers/User/TranscriptVersionController.php b/app/Http/Controllers/User/TranscriptVersionController.php new file mode 100644 index 0000000..46b28fe --- /dev/null +++ b/app/Http/Controllers/User/TranscriptVersionController.php @@ -0,0 +1,67 @@ +authorize('viewVersionHistory', $project); + + $versions = $project->transcriptVersions() + ->with('editor') + ->orderByDesc('version_number') + ->paginate(20); + + return view('user.projects.versions', compact('project', 'versions')); + } + + public function restore( + TranscriptionProject $project, + TranscriptVersion $version, + AuditLogService $audit + ): RedirectResponse { + $this->authorize('restoreVersion', $project); + + // Pastikan versi ini milik projek ini + abort_if($version->project_id !== $project->id, 404); + + $oldText = $project->transcript_text; + $nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1; + + TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => Auth::id(), + 'version_number' => $nextVersion, + 'old_text' => $oldText, + 'new_text' => $version->new_text, + 'change_summary' => "Dipulihkan dari versi {$version->version_number}", + 'created_at' => now(), + ]); + + $project->update([ + 'transcript_text' => $version->new_text, + 'transcription_status' => 'completed', + ]); + + $audit->log('transcript_version_restored', [ + 'project_id' => $project->id, + 'new_values' => [ + 'restored_from_version' => $version->version_number, + 'new_version' => $nextVersion, + ], + ]); + + return redirect() + ->route('user.projects.show', $project) + ->with('success', "Versi {$version->version_number} berjaya dipulihkan."); + } +} diff --git a/app/Http/Middleware/EnsureAdmin.php b/app/Http/Middleware/EnsureAdmin.php new file mode 100644 index 0000000..433ebaa --- /dev/null +++ b/app/Http/Middleware/EnsureAdmin.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->isAdmin()) { + abort(403, 'Akses tidak dibenarkan.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureRegularUser.php b/app/Http/Middleware/EnsureRegularUser.php new file mode 100644 index 0000000..972a103 --- /dev/null +++ b/app/Http/Middleware/EnsureRegularUser.php @@ -0,0 +1,19 @@ +user() || ! $request->user()->isUser()) { + abort(403, 'Akses tidak dibenarkan.'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureUserIsActive.php b/app/Http/Middleware/EnsureUserIsActive.php new file mode 100644 index 0000000..d4685f5 --- /dev/null +++ b/app/Http/Middleware/EnsureUserIsActive.php @@ -0,0 +1,25 @@ +is_active) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login') + ->withErrors(['email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.']); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..18ce2a9 --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,41 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + $response->headers->set( + 'Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline'; " . + "style-src 'self' 'unsafe-inline'; " . + "img-src 'self' data: blob:; " . + "font-src 'self'; " . + "connect-src 'self'; " . + "media-src 'self' blob:; " . + "object-src 'none'; " . + "base-uri 'self'; " . + "form-action 'self'; " . + "frame-ancestors 'self';" + ); + + if ($request->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; + } +} diff --git a/app/Http/Requests/Admin/CreateUserRequest.php b/app/Http/Requests/Admin/CreateUserRequest.php new file mode 100644 index 0000000..6c9831e --- /dev/null +++ b/app/Http/Requests/Admin/CreateUserRequest.php @@ -0,0 +1,36 @@ +user()->isAdmin(); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()], + 'role' => ['required', 'in:admin,user'], + 'department_id' => ['nullable', 'exists:departments,id'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'Nama', + 'email' => 'E-mel', + 'password' => 'Kata laluan', + 'role' => 'Peranan', + 'department_id' => 'Jabatan', + ]; + } +} diff --git a/app/Http/Requests/Admin/TransferOwnershipRequest.php b/app/Http/Requests/Admin/TransferOwnershipRequest.php new file mode 100644 index 0000000..0c151b2 --- /dev/null +++ b/app/Http/Requests/Admin/TransferOwnershipRequest.php @@ -0,0 +1,29 @@ +user()->isAdmin(); + } + + public function rules(): array + { + return [ + 'new_owner_id' => ['required', 'integer', 'exists:users,id'], + 'justification' => ['required', 'string', 'min:10', 'max:1000'], + ]; + } + + public function attributes(): array + { + return [ + 'new_owner_id' => 'Pemilik baru', + 'justification' => 'Justifikasi', + ]; + } +} diff --git a/app/Http/Requests/Admin/UpdateUserEmailRequest.php b/app/Http/Requests/Admin/UpdateUserEmailRequest.php new file mode 100644 index 0000000..2f97891 --- /dev/null +++ b/app/Http/Requests/Admin/UpdateUserEmailRequest.php @@ -0,0 +1,32 @@ +user()->isAdmin(); + } + + public function rules(): array + { + $userId = $this->route('user')->id; + + return [ + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], + 'justification'=> ['nullable', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'email' => 'E-mel baru', + 'justification' => 'Justifikasi', + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..19fbd30 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,64 @@ + ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip()); + } +} diff --git a/app/Http/Requests/User/CreateProjectRequest.php b/app/Http/Requests/User/CreateProjectRequest.php new file mode 100644 index 0000000..e63d0ce --- /dev/null +++ b/app/Http/Requests/User/CreateProjectRequest.php @@ -0,0 +1,52 @@ +user()->isActive(); + } + + public function rules(): array + { + $maxKb = config('speech2text.upload.max_mb', 200) * 1024; + $extensions = implode(',', config('speech2text.upload.allowed_extensions')); + + return [ + 'title' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:2000'], + 'audio' => [ + 'required', + 'file', + "mimes:{$extensions}", + "max:{$maxKb}", + new ValidAudioMagicBytes(), + ], + 'language' => ['nullable', 'string', 'in:ms,en'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'Tajuk projek', + 'description' => 'Penerangan', + 'audio' => 'Fail audio', + 'language' => 'Bahasa', + ]; + } + + public function messages(): array + { + $maxMb = config('speech2text.upload.max_mb', 200); + return [ + 'audio.max' => "Saiz fail audio melebihi had maksimum {$maxMb}MB.", + 'audio.mimes' => 'Format fail tidak disokong. Guna: mp3, wav, m4a, mp4, aac, ogg, flac, webm.', + ]; + } +} diff --git a/app/Http/Requests/User/StoreCommentRequest.php b/app/Http/Requests/User/StoreCommentRequest.php new file mode 100644 index 0000000..a181be5 --- /dev/null +++ b/app/Http/Requests/User/StoreCommentRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:2000'], + ]; + } + + public function messages(): array + { + return [ + 'message.required' => 'Mesej komen tidak boleh kosong.', + 'message.max' => 'Mesej terlalu panjang (maks 2,000 aksara).', + ]; + } +} diff --git a/app/Http/Requests/User/UpdateTranscriptRequest.php b/app/Http/Requests/User/UpdateTranscriptRequest.php new file mode 100644 index 0000000..266632f --- /dev/null +++ b/app/Http/Requests/User/UpdateTranscriptRequest.php @@ -0,0 +1,30 @@ + ['required', 'string', 'max:500000'], + 'change_summary' => ['nullable', 'string', 'max:500'], + ]; + } + + public function messages(): array + { + return [ + 'transcript_text.required' => 'Teks transkripsi tidak boleh kosong.', + 'transcript_text.max' => 'Teks transkripsi terlalu panjang (maks 500,000 aksara).', + 'change_summary.max' => 'Ringkasan perubahan terlalu panjang (maks 500 aksara).', + ]; + } +} diff --git a/app/Http/Requests/User/UploadExternalTranscriptRequest.php b/app/Http/Requests/User/UploadExternalTranscriptRequest.php new file mode 100644 index 0000000..1601b3d --- /dev/null +++ b/app/Http/Requests/User/UploadExternalTranscriptRequest.php @@ -0,0 +1,32 @@ + ['required', 'file', 'mimetypes:text/plain', 'extensions:txt', 'max:10240'], + 'clean_with_ollama' => ['nullable', 'boolean'], + 'confirmed' => ['nullable', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'transcript_file.required' => 'Sila pilih fail transkripsi (.txt).', + 'transcript_file.mimetypes' => 'Fail mesti berformat teks biasa (.txt).', + 'transcript_file.extensions'=> 'Hanya fail .txt dibenarkan.', + 'transcript_file.max' => 'Saiz fail terlalu besar (maks 10 MB).', + ]; + } +} diff --git a/app/Jobs/OllamaPostProcessJob.php b/app/Jobs/OllamaPostProcessJob.php new file mode 100644 index 0000000..526d21f --- /dev/null +++ b/app/Jobs/OllamaPostProcessJob.php @@ -0,0 +1,76 @@ +project; + + // Reload fresh — status may have changed between dispatch and execution + $project->refresh(); + + if (! $project->isCompleted() || ! $project->transcript_text) { + return; + } + + if (! $ollama->isAvailable()) { + Log::info("OllamaPostProcessJob: Ollama tidak tersedia, skip projek #{$project->id}"); + return; + } + + $cleaned = $ollama->cleanTranscript($project->transcript_text, $project->language); + + if ($cleaned === null) { + Log::warning("OllamaPostProcessJob: hasil null untuk projek #{$project->id}"); + return; + } + + $oldText = $project->transcript_text; + $nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1; + + TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => $project->owner_user_id, + 'version_number' => $nextVersion, + 'old_text' => $oldText, + 'new_text' => $cleaned, + 'change_summary' => 'Dibersihkan oleh Ollama', + 'created_at' => now(), + ]); + + $project->update(['transcript_text' => $cleaned]); + + AuditLog::create([ + 'actor_user_id' => null, + 'actor_role' => 'system', + 'action' => 'transcript_postprocessed', + 'project_id' => $project->id, + 'new_values' => [ + 'project_uuid' => $project->uuid, + 'model' => config('speech2text.ollama.model'), + ], + 'created_at' => now(), + ]); + } +} diff --git a/app/Jobs/TranscribeAudioJob.php b/app/Jobs/TranscribeAudioJob.php new file mode 100644 index 0000000..d14ba03 --- /dev/null +++ b/app/Jobs/TranscribeAudioJob.php @@ -0,0 +1,120 @@ +project; + + $project->update(['transcription_status' => 'processing']); + $this->auditLog('transcription_started'); + + $tmpPath = null; + + try { + $path = $project->stored_audio_path; + + abort_unless($storage->exists($path), 500, 'Fail audio tidak dijumpai.'); + + // Stream storage → temp file to avoid loading large files into memory + $tmpPath = tempnam(sys_get_temp_dir(), 'whisper_'); + $src = $storage->readStream($path); + $dst = fopen($tmpPath, 'wb'); + + stream_copy_to_stream($src, $dst); + + fclose($src); + fclose($dst); + + $workerUrl = rtrim(config('speech2text.transcription.worker_url'), '/'); + + $response = Http::timeout(540) + ->attach( + 'audio', + fopen($tmpPath, 'rb'), + basename($path), + ['Content-Type' => $project->mime_type] + ) + ->post("{$workerUrl}/transcribe"); + + if (! $response->successful()) { + throw new \RuntimeException("Transcription worker mengembalikan HTTP {$response->status()}"); + } + + $result = $response->json(); + + if (! ($result['success'] ?? false)) { + throw new \RuntimeException($result['error'] ?? 'Transcription gagal tanpa mesej ralat.'); + } + + $project->update([ + 'transcription_status' => 'completed', + 'transcript_text' => $result['transcript'] ?? '', + 'transcript_confidence' => $result['confidence'] ?? null, + 'duration_seconds' => $result['duration_seconds'] ?? null, + 'error_message' => null, + 'processed_at' => now(), + ]); + + $this->auditLog('transcription_completed', [ + 'duration_seconds' => $result['duration_seconds'] ?? null, + 'engine' => config('speech2text.transcription.engine'), + ]); + + // Optional Ollama post-processing + if (config('speech2text.ollama.enabled') && $project->transcript_text) { + OllamaPostProcessJob::dispatch($project); + } + + } catch (\Throwable $e) { + Log::error("TranscribeAudioJob gagal untuk project #{$project->id}: {$e->getMessage()}"); + + $project->update([ + 'transcription_status' => 'failed', + 'error_message' => $e->getMessage(), + ]); + + $this->auditLog('transcription_failed', ['error' => $e->getMessage()]); + + $this->fail($e); + + } finally { + if ($tmpPath && file_exists($tmpPath)) { + @unlink($tmpPath); + } + } + } + + private function auditLog(string $action, array $extra = []): void + { + AuditLog::create([ + 'actor_user_id' => null, + 'actor_role' => 'system', + 'action' => $action, + 'project_id' => $this->project->id, + 'new_values' => array_merge(['project_uuid' => $this->project->uuid], $extra), + 'created_at' => now(), + ]); + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..cb6e556 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,46 @@ + 'array', + 'new_values' => 'array', + 'created_at' => 'datetime', + ]; + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_user_id'); + } + + public function targetUser(): BelongsTo + { + return $this->belongsTo(User::class, 'target_user_id'); + } +} diff --git a/app/Models/Department.php b/app/Models/Department.php new file mode 100644 index 0000000..e61cf29 --- /dev/null +++ b/app/Models/Department.php @@ -0,0 +1,24 @@ + 'boolean']; + } + + public function users(): HasMany + { + return $this->hasMany(User::class); + } +} diff --git a/app/Models/ProjectCollaborator.php b/app/Models/ProjectCollaborator.php new file mode 100644 index 0000000..b62d98e --- /dev/null +++ b/app/Models/ProjectCollaborator.php @@ -0,0 +1,26 @@ +belongsTo(TranscriptionProject::class, 'project_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function addedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'added_by'); + } +} diff --git a/app/Models/ProjectComment.php b/app/Models/ProjectComment.php new file mode 100644 index 0000000..6fc1092 --- /dev/null +++ b/app/Models/ProjectComment.php @@ -0,0 +1,24 @@ +belongsTo(TranscriptionProject::class, 'project_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/TranscriptVersion.php b/app/Models/TranscriptVersion.php new file mode 100644 index 0000000..758f8a6 --- /dev/null +++ b/app/Models/TranscriptVersion.php @@ -0,0 +1,36 @@ + 'datetime']; + } + + public function project(): BelongsTo + { + return $this->belongsTo(TranscriptionProject::class, 'project_id'); + } + + public function editor(): BelongsTo + { + return $this->belongsTo(User::class, 'edited_by'); + } +} diff --git a/app/Models/TranscriptionProject.php b/app/Models/TranscriptionProject.php new file mode 100644 index 0000000..e96a89d --- /dev/null +++ b/app/Models/TranscriptionProject.php @@ -0,0 +1,153 @@ + 'encrypted', // enkripsi kandungan sensitif + 'transcript_confidence' => 'float', + 'processed_at' => 'datetime', + 'file_size' => 'integer', + 'duration_seconds' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $project) { + if (empty($project->uuid)) { + $project->uuid = (string) Str::uuid(); + } + }); + } + + // Route model binding gunakan uuid + public function getRouteKeyName(): string + { + return 'uuid'; + } + + // ------------------------------------------------------- + // Relationships + // ------------------------------------------------------- + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function collaborators(): BelongsToMany + { + return $this->belongsToMany(User::class, 'project_collaborators', 'project_id', 'user_id') + ->withPivot('role', 'added_by') + ->withTimestamps(); + } + + public function projectCollaborators(): HasMany + { + return $this->hasMany(ProjectCollaborator::class, 'project_id'); + } + + public function transcriptVersions(): HasMany + { + return $this->hasMany(TranscriptVersion::class, 'project_id')->orderByDesc('version_number'); + } + + public function comments(): HasMany + { + return $this->hasMany(ProjectComment::class, 'project_id')->latest(); + } + + // ------------------------------------------------------- + // Helpers + // ------------------------------------------------------- + + public function isOwnedBy(User $user): bool + { + return $this->owner_user_id === $user->id; + } + + public function hasCollaborator(User $user): bool + { + return $this->collaborators()->where('user_id', $user->id)->exists(); + } + + public function isAccessibleBy(User $user): bool + { + return $this->isOwnedBy($user) || $this->hasCollaborator($user); + } + + public function isPending(): bool + { + return $this->transcription_status === 'pending'; + } + + public function isProcessing(): bool + { + return $this->transcription_status === 'processing'; + } + + public function isCompleted(): bool + { + return $this->transcription_status === 'completed'; + } + + public function isFailed(): bool + { + return $this->transcription_status === 'failed'; + } + + public function fileSizeForHumans(): string + { + $bytes = $this->file_size; + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } + if ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } + return number_format($bytes / 1024, 2) . ' KB'; + } + + public function durationForHumans(): ?string + { + if (! $this->duration_seconds) { + return null; + } + $m = intdiv($this->duration_seconds, 60); + $s = $this->duration_seconds % 60; + return sprintf('%d:%02d', $m, $s); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..c8f7a69 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,106 @@ + 'datetime', + 'last_login_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', + ]; + } + + // ------------------------------------------------------- + // Role helpers + // ------------------------------------------------------- + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } + + public function isUser(): bool + { + return $this->role === 'user'; + } + + public function isActive(): bool + { + return (bool) $this->is_active; + } + + // ------------------------------------------------------- + // Relationships + // ------------------------------------------------------- + + public function department(): BelongsTo + { + return $this->belongsTo(Department::class); + } + + public function ownedProjects(): HasMany + { + return $this->hasMany(TranscriptionProject::class, 'owner_user_id'); + } + + public function collaboratingProjects(): BelongsToMany + { + return $this->belongsToMany( + TranscriptionProject::class, + 'project_collaborators', + 'user_id', + 'project_id' + )->withPivot('role', 'added_by')->withTimestamps(); + } + + public function auditActionsPerformed(): HasMany + { + return $this->hasMany(AuditLog::class, 'actor_user_id'); + } + + public function auditActionsTargeted(): HasMany + { + return $this->hasMany(AuditLog::class, 'target_user_id'); + } + + // ------------------------------------------------------- + // Usage check (for safe delete) + // ------------------------------------------------------- + + public function hasUsage(): bool + { + return $this->ownedProjects()->withTrashed()->exists() + || $this->collaboratingProjects()->exists() + || AuditLog::where('actor_user_id', $this->id)->exists(); + } +} diff --git a/app/Policies/CommentPolicy.php b/app/Policies/CommentPolicy.php new file mode 100644 index 0000000..65b5b6c --- /dev/null +++ b/app/Policies/CommentPolicy.php @@ -0,0 +1,26 @@ +isAccessibleBy($user); + } + + public function create(User $user, TranscriptionProject $project): bool + { + return $project->isAccessibleBy($user); + } + + public function delete(User $user, ProjectComment $comment): bool + { + $project = $comment->project; + return $comment->user_id === $user->id || $project->isOwnedBy($user); + } +} diff --git a/app/Policies/TranscriptVersionPolicy.php b/app/Policies/TranscriptVersionPolicy.php new file mode 100644 index 0000000..2df0a60 --- /dev/null +++ b/app/Policies/TranscriptVersionPolicy.php @@ -0,0 +1,26 @@ +project->isAccessibleBy($user); + } + + public function restore(User $user, TranscriptVersion $version): bool + { + $project = $version->project; + if ($project->isOwnedBy($user)) { + return true; + } + return $project->collaborators() + ->where('user_id', $user->id) + ->wherePivot('role', 'editor') + ->exists(); + } +} diff --git a/app/Policies/TranscriptionProjectPolicy.php b/app/Policies/TranscriptionProjectPolicy.php new file mode 100644 index 0000000..9b75ca3 --- /dev/null +++ b/app/Policies/TranscriptionProjectPolicy.php @@ -0,0 +1,90 @@ +isAccessibleBy($user); + } + + // Admin TIDAK boleh view metadata content — hanya metadata minimum via viewMetadata + public function viewMetadata(User $user, TranscriptionProject $project): bool + { + return $user->isAdmin() || $project->isAccessibleBy($user); + } + + public function create(User $user): bool + { + // Admin juga kakitangan — boleh cipta projek sendiri + return $user->isActive(); + } + + public function update(User $user, TranscriptionProject $project): bool + { + return $project->isAccessibleBy($user); + } + + public function delete(User $user, TranscriptionProject $project): bool + { + return $project->isOwnedBy($user); + } + + public function viewAudio(User $user, TranscriptionProject $project): bool + { + return $project->isAccessibleBy($user); + } + + public function viewTranscript(User $user, TranscriptionProject $project): bool + { + return $project->isAccessibleBy($user); + } + + public function editTranscript(User $user, TranscriptionProject $project): bool + { + if ($project->isOwnedBy($user)) { + return true; + } + // Viewer collaborator tidak boleh edit — editor sahaja + return $project->collaborators() + ->where('user_id', $user->id) + ->wherePivot('role', 'editor') + ->exists(); + } + + public function manageCollaborators(User $user, TranscriptionProject $project): bool + { + return $project->isOwnedBy($user); + } + + public function retryTranscription(User $user, TranscriptionProject $project): bool + { + return $project->isOwnedBy($user) && $project->isFailed(); + } + + public function transferOwner(User $user, TranscriptionProject $project): bool + { + return $user->isAdmin(); + } + + public function viewVersionHistory(User $user, TranscriptionProject $project): bool + { + return $project->isAccessibleBy($user); + } + + public function restoreVersion(User $user, TranscriptionProject $project): bool + { + if ($project->isOwnedBy($user)) { + return true; + } + return $project->collaborators() + ->where('user_id', $user->id) + ->wherePivot('role', 'editor') + ->exists(); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..cf5dabc --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,45 @@ +isAdmin(); + } + + public function create(User $actor): bool + { + return $actor->isAdmin(); + } + + public function update(User $actor, User $target): bool + { + return $actor->isAdmin() && $actor->id !== $target->id; + } + + public function delete(User $actor, User $target): bool + { + return $actor->isAdmin() + && $actor->id !== $target->id + && ! $target->hasUsage(); + } + + public function activate(User $actor, User $target): bool + { + return $actor->isAdmin() && $actor->id !== $target->id; + } + + public function deactivate(User $actor, User $target): bool + { + return $actor->isAdmin() && $actor->id !== $target->id; + } + + public function changeEmail(User $actor, User $target): bool + { + return $actor->isAdmin() && $actor->id !== $target->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..53fcd5d --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,40 @@ +by($request->input('email') . '|' . $request->ip()); + }); + + RateLimiter::for('upload', function (Request $request) { + return Limit::perHour(10)->by($request->user()?->id ?: $request->ip()); + }); + } +} diff --git a/app/Rules/ValidAudioMagicBytes.php b/app/Rules/ValidAudioMagicBytes.php new file mode 100644 index 0000000..55dd568 --- /dev/null +++ b/app/Rules/ValidAudioMagicBytes.php @@ -0,0 +1,77 @@ +isValid()) { + $fail('Fail audio tidak sah.'); + return; + } + + $handle = @fopen($value->getRealPath(), 'rb'); + if ($handle === false) { + $fail('Fail audio tidak dapat dibaca.'); + return; + } + + $header = fread($handle, 12); + fclose($handle); + + if (strlen($header) < 4) { + $fail('Fail audio terlalu kecil atau rosak.'); + return; + } + + $bytes = array_values(unpack('C*', $header)); + + foreach (self::SIGNATURES as $sig) { + if ($this->matchesAt($bytes, $sig, 0)) { + return; + } + } + + // Check MP4/M4A: need at least 8 bytes + if (count($bytes) >= self::MP4_OFFSET + count(self::MP4_SIGNATURE)) { + if ($this->matchesAt($bytes, self::MP4_SIGNATURE, self::MP4_OFFSET)) { + return; + } + } + + $fail('Format fail audio tidak sah. Kandungan fail tidak menepati format audio yang dibenarkan.'); + } + + private function matchesAt(array $bytes, array $signature, int $offset): bool + { + foreach ($signature as $i => $expected) { + if (($bytes[$offset + $i] ?? -1) !== $expected) { + return false; + } + } + return true; + } +} diff --git a/app/Services/AuditLogService.php b/app/Services/AuditLogService.php new file mode 100644 index 0000000..393a14c --- /dev/null +++ b/app/Services/AuditLogService.php @@ -0,0 +1,35 @@ + $actor?->id ?? $options['actor_user_id'] ?? null, + 'actor_role' => $actor?->role ?? $options['actor_role'] ?? 'system', + 'action' => $action, + 'subject_type' => $options['subject_type'] ?? null, + 'subject_id' => $options['subject_id'] ?? null, + 'target_user_id'=> $options['target_user_id'] ?? null, + 'project_id' => $options['project_id'] ?? null, + 'old_values' => $options['old_values'] ?? null, + 'new_values' => $options['new_values'] ?? null, + 'justification' => $options['justification'] ?? null, + 'ip_address' => $this->request->ip(), + 'user_agent' => $this->request->userAgent(), + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/OllamaService.php b/app/Services/OllamaService.php new file mode 100644 index 0000000..83cf6b6 --- /dev/null +++ b/app/Services/OllamaService.php @@ -0,0 +1,71 @@ +baseUrl = rtrim(config('speech2text.ollama.base_url'), '/'); + $this->model = config('speech2text.ollama.model'); + } + + /** + * Clean and improve raw transcript text using a local Ollama model. + * Returns corrected text, or null if Ollama is unavailable. + */ + public function cleanTranscript(string $rawText, string $language = 'ms'): ?string + { + $langLabel = $language === 'ms' ? 'Bahasa Melayu' : 'English'; + + $prompt = <<post("{$this->baseUrl}/api/generate", [ + 'model' => $this->model, + 'prompt' => $prompt, + 'stream' => false, + ]); + + if (! $response->successful()) { + Log::warning("OllamaService: HTTP {$response->status()} dari {$this->baseUrl}"); + return null; + } + + $text = trim($response->json('response') ?? ''); + + return $text !== '' ? $text : null; + + } catch (\Throwable $e) { + Log::warning("OllamaService tidak tersedia: {$e->getMessage()}"); + return null; + } + } + + public function isAvailable(): bool + { + try { + return Http::timeout(5)->get("{$this->baseUrl}/api/tags")->successful(); + } catch (\Throwable) { + return false; + } + } +} diff --git a/app/Services/StorageService.php b/app/Services/StorageService.php new file mode 100644 index 0000000..a3f3847 --- /dev/null +++ b/app/Services/StorageService.php @@ -0,0 +1,45 @@ +getClientOriginalExtension(); + $filename = Str::random(40) . '.' . strtolower($ext); + $dir = "transcriptions/{$uuid}/audio"; + + Storage::disk(self::DISK)->putFileAs($dir, $file, $filename); + + return "{$dir}/{$filename}"; + } + + public function delete(string $path): void + { + if (Storage::disk(self::DISK)->exists($path)) { + Storage::disk(self::DISK)->delete($path); + } + } + + public function exists(string $path): bool + { + return Storage::disk(self::DISK)->exists($path); + } + + public function size(string $path): int + { + return Storage::disk(self::DISK)->size($path); + } + + public function readStream(string $path) + { + return Storage::disk(self::DISK)->readStream($path); + } +} diff --git a/artisan b/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..9ab66be --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,31 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->alias([ + 'active' => EnsureUserIsActive::class, + 'admin' => EnsureAdmin::class, + 'user.role' => EnsureRegularUser::class, + ]); + + $middleware->web(append: [ + SecurityHeaders::class, + EnsureUserIsActive::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "aec528da477062d3af11f51e6b33402be233b21f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/aec528da477062d3af11f51e6b33402be233b21f", + "reference": "aec528da477062d3af11f51e6b33402be233b21f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-05-22T19:00:53+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2", + "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.4.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-05-20T22:57:30+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361", + "reference": "73ab136360b5dfd858006eae9795e8fe43c80361", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.10.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-05-20T09:27:36+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.11.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "4148042bf6ee01edd05408f1f66d91b231f85c25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/4148042bf6ee01edd05408f1f66d91b231f85c25", + "reference": "4148042bf6ee01edd05408f1f66d91b231f85c25", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/psr7": "^2.9", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-05-20T11:46:02+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/console", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4|^8.0" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "3665cfade90565430909b906394c73c8739e57d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:51:42+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8da41214757b87d97f181e3d14a4179286151007" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/c00291734c59c05c54c5a3abc2ab18e99b070157", + "reference": "c00291734c59c05c54c5a3abc2ab18e99b070157", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/translation-contracts": "<2.5", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T09:47:36+00:00" + }, + { + "name": "symfony/mailer", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5266d594e83593dff3492b5655ff6e8f38d67cfc", + "reference": "5266d594e83593dff3492b5655ff6e8f38d67cfc", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", + "reference": "7d9a72bbf0a9cb169ed1cbbbbbf709a592207fc1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T18:47:49+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:10:57+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-26T13:13:48+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:56:32+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.0.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/c7f22a665faa3e5212b8f042e0c5831a6b85492f", + "reference": "c7f22a665faa3e5212b8f042e0c5831a6b85492f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.0.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:22:03+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-13T12:07:53+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.0.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.0.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T16:10:06+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-31T07:15:36+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-02-09T13:44:54+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/02f62a64c2b60af44a418ee490fee193590d8269", + "reference": "02f62a64c2b60af44a418ee490fee193590d8269", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.0", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.6.3 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.1.51", + "rector/rector": "^2.4.2", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.0.8" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-04-27T22:37:26+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-04-15T08:23:17+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.26", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "reference": "e78c9ad74f73fd3642a23e65ace83746dc8df26d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.1", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.26" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-05-21T12:36:53+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "reference": "334bc42a97ec6fc44c59001dc3467e0d739a20e9", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-21T08:45:32+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..423eed5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..337c068 --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,89 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => false, // jangan serve direct — mesti melalui controller + 'throw' => false, + 'report' => false, + ], + + // Disk khas untuk audio dan transcript (private, tidak accessible web) + 'private' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => false, + 'throw' => true, + 'report' => true, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..6a90eb8 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..f574482 --- /dev/null +++ b/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/config/speech2text.php b/config/speech2text.php new file mode 100644 index 0000000..09f60a5 --- /dev/null +++ b/config/speech2text.php @@ -0,0 +1,60 @@ + [ + 'engine' => env('TRANSCRIPTION_ENGINE', 'faster-whisper'), + 'worker_url' => env('TRANSCRIPTION_WORKER_URL', 'http://transcription-worker:8000'), + 'model' => env('WHISPER_MODEL', 'small'), + 'language' => env('WHISPER_LANGUAGE', 'ms'), + ], + + /* + |-------------------------------------------------------------------------- + | Upload Limits + |-------------------------------------------------------------------------- + */ + 'upload' => [ + 'max_mb' => (int) env('PRIVATE_AUDIO_MAX_MB', 200), + 'allowed_mimes' => [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/x-wav', + 'audio/mp4', + 'audio/x-m4a', + 'audio/aac', + 'video/mp4', + 'audio/ogg', + 'audio/flac', + 'audio/webm', + ], + 'allowed_extensions' => ['mp3', 'wav', 'm4a', 'mp4', 'aac', 'ogg', 'flac', 'webm'], + ], + + /* + |-------------------------------------------------------------------------- + | Ollama — Optional Post-processing + |-------------------------------------------------------------------------- + */ + 'ollama' => [ + 'enabled' => env('OLLAMA_ENABLED', false), + 'base_url' => env('OLLAMA_BASE_URL', 'http://ollama:11434'), + 'model' => env('OLLAMA_MODEL', 'llama3.1'), + ], + + /* + |-------------------------------------------------------------------------- + | Storage Path Templates + |-------------------------------------------------------------------------- + */ + 'storage' => [ + 'audio_path' => 'transcriptions/{uuid}/audio', + ], + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/TranscriptionProjectFactory.php b/database/factories/TranscriptionProjectFactory.php new file mode 100644 index 0000000..226c348 --- /dev/null +++ b/database/factories/TranscriptionProjectFactory.php @@ -0,0 +1,53 @@ + */ +class TranscriptionProjectFactory extends Factory +{ + public function definition(): array + { + return [ + 'uuid' => (string) Str::uuid(), + 'title' => fake()->sentence(4), + 'description' => fake()->optional()->sentence(), + 'owner_user_id' => User::factory(), + 'original_filename' => fake()->word() . '.mp3', + 'stored_audio_path' => 'transcriptions/' . Str::uuid() . '/audio/' . Str::random(20) . '.mp3', + 'mime_type' => 'audio/mpeg', + 'file_size' => fake()->numberBetween(100000, 50000000), + 'duration_seconds' => fake()->optional()->numberBetween(60, 3600), + 'language' => 'ms', + 'transcription_status' => 'pending', + 'transcription_engine' => 'faster-whisper', + 'transcript_text' => null, + ]; + } + + public function completed(): static + { + return $this->state(fn () => [ + 'transcription_status' => 'completed', + 'transcript_text' => fake()->paragraphs(3, true), + 'processed_at' => now(), + ]); + } + + public function failed(): static + { + return $this->state(fn () => [ + 'transcription_status' => 'failed', + 'error_message' => 'Connection refused', + ]); + } + + public function processing(): static + { + return $this->state(fn () => ['transcription_status' => 'processing']); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..8220f03 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,43 @@ + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'role' => 'user', + 'department_id' => null, + 'is_active' => true, + 'remember_token' => Str::random(10), + ]; + } + + public function admin(): static + { + return $this->state(fn () => ['role' => 'admin']); + } + + public function inactive(): static + { + return $this->state(fn () => ['is_active' => false]); + } + + public function unverified(): static + { + return $this->state(fn () => ['email_verified_at' => null]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..b4b611a --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('name'); + $table->string('code', 50)->nullable()->unique(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->enum('role', ['admin', 'user'])->default('user'); + $table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_login_at')->nullable(); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + Schema::dropIfExists('departments'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..edac6fe --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2024_01_01_000010_create_audit_logs_table.php b/database/migrations/2024_01_01_000010_create_audit_logs_table.php new file mode 100644 index 0000000..879ec75 --- /dev/null +++ b/database/migrations/2024_01_01_000010_create_audit_logs_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('actor_role', 50)->nullable(); + $table->string('action', 100)->index(); + $table->string('subject_type', 100)->nullable(); + $table->unsignedBigInteger('subject_id')->nullable(); + $table->foreignId('target_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedBigInteger('project_id')->nullable(); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->text('justification')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('created_at')->nullable()->index(); + + $table->index(['subject_type', 'subject_id']); + $table->index('project_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2024_01_01_000020_create_transcription_projects_table.php b/database/migrations/2024_01_01_000020_create_transcription_projects_table.php new file mode 100644 index 0000000..f3d99b1 --- /dev/null +++ b/database/migrations/2024_01_01_000020_create_transcription_projects_table.php @@ -0,0 +1,76 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('title'); + $table->text('description')->nullable(); + $table->foreignId('owner_user_id')->constrained('users')->restrictOnDelete(); + $table->string('original_filename', 500); + $table->string('stored_audio_path', 1000); + $table->string('mime_type', 100); + $table->unsignedBigInteger('file_size'); + $table->unsignedInteger('duration_seconds')->nullable(); + $table->string('language', 10)->default('ms'); + $table->enum('transcription_status', ['pending', 'processing', 'completed', 'failed'])->default('pending')->index(); + $table->string('transcription_engine', 50)->nullable(); + $table->longText('transcript_text')->nullable(); // encrypted via model cast + $table->decimal('transcript_confidence', 5, 4)->nullable(); + $table->text('error_message')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('owner_user_id'); + }); + + Schema::create('project_collaborators', function (Blueprint $table) { + $table->id(); + $table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('role', ['editor', 'viewer'])->default('editor'); + $table->foreignId('added_by')->constrained('users')->restrictOnDelete(); + $table->timestamps(); + + $table->unique(['project_id', 'user_id']); + }); + + Schema::create('transcript_versions', function (Blueprint $table) { + $table->id(); + $table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete(); + $table->foreignId('edited_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('version_number'); + $table->longText('old_text')->nullable(); + $table->longText('new_text'); + $table->string('change_summary', 500)->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['project_id', 'version_number']); + }); + + Schema::create('project_comments', function (Blueprint $table) { + $table->id(); + $table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->restrictOnDelete(); + $table->text('message'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('project_comments'); + Schema::dropIfExists('transcript_versions'); + Schema::dropIfExists('project_collaborators'); + Schema::dropIfExists('transcription_projects'); + } +}; diff --git a/database/seeders/AdminUserSeeder.php b/database/seeders/AdminUserSeeder.php new file mode 100644 index 0000000..b741536 --- /dev/null +++ b/database/seeders/AdminUserSeeder.php @@ -0,0 +1,41 @@ +command->warn( + 'Admin default tidak dicipta. Sila isi ADMIN_DEFAULT_NAME, ADMIN_DEFAULT_EMAIL, dan ADMIN_DEFAULT_PASSWORD dalam .env' + ); + return; + } + + $admin = User::firstOrCreate( + ['email' => $email], + [ + 'name' => $name, + 'password' => Hash::make($password), + 'role' => 'admin', + 'is_active' => true, + 'email_verified_at' => now(), + ] + ); + + if ($admin->wasRecentlyCreated) { + $this->command->info("Admin '{$name}' ({$email}) berjaya dicipta."); + } else { + $this->command->info("Admin '{$email}' sudah wujud. Tiada perubahan."); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..bf6d377 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,16 @@ +call([ + DepartmentSeeder::class, + AdminUserSeeder::class, + ]); + } +} diff --git a/database/seeders/DepartmentSeeder.php b/database/seeders/DepartmentSeeder.php new file mode 100644 index 0000000..1621c67 --- /dev/null +++ b/database/seeders/DepartmentSeeder.php @@ -0,0 +1,29 @@ + 'Jabatan Pentadbiran', 'code' => 'JPA'], + ['name' => 'Jabatan Kewangan', 'code' => 'JKW'], + ['name' => 'Jabatan Kejuruteraan', 'code' => 'JKJ'], + ['name' => 'Jabatan Perancangan Bandar', 'code' => 'JPB'], + ['name' => 'Jabatan Kesihatan Persekitaran', 'code' => 'JKP'], + ['name' => 'Jabatan Pelesenan', 'code' => 'JPL'], + ['name' => 'Jabatan Undang-Undang', 'code' => 'JUU'], + ['name' => 'Jabatan Teknologi Maklumat', 'code' => 'JTM'], + ['name' => 'Jabatan Landskap & Rekreasi', 'code' => 'JLR'], + ['name' => 'Jabatan Penguatkuasaan', 'code' => 'JPK'], + ]; + + foreach ($departments as $dept) { + Department::firstOrCreate(['code' => $dept['code']], $dept); + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2a3a1ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,167 @@ +name: speech2text-mbip + +services: + # ============================================================ + # Nginx — Web Server + # ============================================================ + nginx: + image: nginx:1.25-alpine + container_name: speech2text_nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./:/var/www/html:ro + - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - app + networks: + - speech2text_net + + # ============================================================ + # App — Laravel PHP-FPM + # ============================================================ + app: + build: + context: . + dockerfile: Dockerfile + container_name: speech2text_app + restart: unless-stopped + environment: + - APP_ENV=${APP_ENV:-production} + volumes: + - ./:/var/www/html + - ./docker/php/php.ini:/usr/local/etc/php/conf.d/app.ini:ro + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + networks: + - speech2text_net + + # ============================================================ + # MySQL 8 + # ============================================================ + mysql: + image: mysql:8.0 + container_name: speech2text_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE:-speech2text} + MYSQL_USER: ${MYSQL_USER:-speech2text} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + ports: + - "127.0.0.1:3307:3306" # host:3307 → container:3306 (elak conflict jika ada projek lain) + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - speech2text_net + + # ============================================================ + # Redis — Queue & Cache + # ============================================================ + redis: + image: redis:7-alpine + container_name: speech2text_redis + restart: unless-stopped + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis_data:/data + networks: + - speech2text_net + + # ============================================================ + # Queue Worker — Laravel + # ============================================================ + queue-worker: + build: + context: . + dockerfile: Dockerfile + container_name: speech2text_queue + restart: unless-stopped + command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256 + volumes: + - ./:/var/www/html + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + networks: + - speech2text_net + + # ============================================================ + # Scheduler — Laravel Cron + # ============================================================ + scheduler: + build: + context: . + dockerfile: Dockerfile + container_name: speech2text_scheduler + restart: unless-stopped + command: php artisan schedule:work + volumes: + - ./:/var/www/html + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_started + networks: + - speech2text_net + + # ============================================================ + # Transcription Worker — Python FastAPI + faster-whisper + # INTERNAL ONLY — tidak boleh diakses dari luar Docker network + # ============================================================ + transcription-worker: + build: + context: ./docker/transcription-worker + dockerfile: Dockerfile + container_name: speech2text_transcription + restart: unless-stopped + environment: + WHISPER_MODEL: ${WHISPER_MODEL:-small} + WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-ms} + WHISPER_DEVICE: ${WHISPER_DEVICE:-cpu} + WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-int8} + volumes: + - whisper_models:/root/.cache/huggingface # cache model agar tidak download ulang + # Port TIDAK di-expose ke host — internal sahaja + networks: + - speech2text_net + + # ============================================================ + # Ollama — Optional Local LLM (disabled by default) + # Uncomment jika OLLAMA_ENABLED=true + # ============================================================ + # ollama: + # image: ollama/ollama + # container_name: speech2text_ollama + # restart: unless-stopped + # volumes: + # - ollama_data:/root/.ollama + # networks: + # - speech2text_net + +networks: + speech2text_net: + driver: bridge + +volumes: + mysql_data: + name: speech2text_mysql_data # nama eksplisit — susah ter-delete secara tidak sengaja + labels: + com.speech2text.description: "MySQL production data — JANGAN PADAM" + redis_data: + name: speech2text_redis_data + whisper_models: + name: speech2text_whisper_models + # ollama_data: diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..db08206 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name _; + root /var/www/html/public; + index index.php index.html; + + client_max_body_size 210M; + + add_header X-Frame-Options "DENY"; + add_header X-XSS-Protection "1; mode=block"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_read_timeout 300; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + } + + # Deny direct access to storage (private files served via PHP) + location ~ ^/storage/ { + deny all; + } +} diff --git a/docker/php-apache/Dockerfile b/docker/php-apache/Dockerfile new file mode 100644 index 0000000..e56f303 --- /dev/null +++ b/docker/php-apache/Dockerfile @@ -0,0 +1,25 @@ +FROM php:8.5-apache + +ENV APACHE_DOCUMENT_ROOT=/var/www/html/public + +RUN apt-get update && apt-get install -y \ + git \ + unzip \ + libzip-dev \ + libpng-dev \ + libjpeg62-turbo-dev \ + libfreetype6-dev \ + libicu-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install pdo_mysql gd intl zip \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && a2enmod rewrite headers \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +COPY docker/php-apache/vhost.conf /etc/apache2/sites-available/000-default.conf + +WORKDIR /var/www/html + +RUN chown -R www-data:www-data /var/www/html diff --git a/docker/php-apache/vhost.conf b/docker/php-apache/vhost.conf new file mode 100644 index 0000000..aec7291 --- /dev/null +++ b/docker/php-apache/vhost.conf @@ -0,0 +1,13 @@ + + ServerAdmin webmaster@localhost + DocumentRoot /var/www/html/public + + + AllowOverride All + Require all granted + Options -Indexes + + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + diff --git a/docker/php/custom.ini b/docker/php/custom.ini new file mode 100644 index 0000000..4759bf7 --- /dev/null +++ b/docker/php/custom.ini @@ -0,0 +1,4 @@ +display_errors = On +display_startup_errors = Off +log_errors = On +error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED \ No newline at end of file diff --git a/docker/php/php.ini b/docker/php/php.ini new file mode 100644 index 0000000..28cd270 --- /dev/null +++ b/docker/php/php.ini @@ -0,0 +1,34 @@ +; Speech2Text MBIP — PHP Configuration + +[PHP] +upload_max_filesize = 210M +post_max_size = 215M +max_execution_time = 300 +max_input_time = 300 +memory_limit = 512M + +; Security +expose_php = Off +display_errors = Off +log_errors = On +error_log = /var/log/php_errors.log + +; File uploads +file_uploads = On +upload_tmp_dir = /tmp + +; Session +session.cookie_httponly = 1 +session.cookie_secure = 1 +session.use_strict_mode = 1 +session.gc_maxlifetime = 7200 + +; Timezone +date.timezone = Asia/Kuala_Lumpur + +[opcache] +opcache.enable = 1 +opcache.memory_consumption = 128 +opcache.max_accelerated_files = 10000 +opcache.revalidate_freq = 0 +opcache.validate_timestamps = 0 diff --git a/docker/supervisor/supervisord.conf b/docker/supervisor/supervisord.conf new file mode 100644 index 0000000..7190a24 --- /dev/null +++ b/docker/supervisor/supervisord.conf @@ -0,0 +1,27 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:php-fpm] +command=php-fpm +autostart=true +autorestart=true +priority=5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:queue-worker] +process_name=%(program_name)s_%(process_num)02d +command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +numprocs=2 +redirect_stderr=true +stdout_logfile=/var/log/supervisor/queue-worker.log +stopwaitsecs=3600 diff --git a/docker/transcription-worker/Dockerfile b/docker/transcription-worker/Dockerfile new file mode 100644 index 0000000..9a839ca --- /dev/null +++ b/docker/transcription-worker/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies for faster-whisper +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY main.py . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/docker/transcription-worker/main.py b/docker/transcription-worker/main.py new file mode 100644 index 0000000..09c0195 --- /dev/null +++ b/docker/transcription-worker/main.py @@ -0,0 +1,125 @@ +""" +Speech2Text MBIP — Transcription Worker +FastAPI service wrapping faster-whisper for Bahasa Melayu transcription. +Internal-only service; not exposed to internet. +""" + +import os +import tempfile +import logging +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, UploadFile, File, HTTPException +from pydantic import BaseModel +from faster_whisper import WhisperModel + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Speech2Text Transcription Worker", docs_url=None, redoc_url=None) + +# Config from environment +MODEL_SIZE = os.getenv("WHISPER_MODEL", "small") +LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ms") +DEVICE = os.getenv("WHISPER_DEVICE", "cpu") +COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") + +logger.info(f"Loading Whisper model: {MODEL_SIZE}, device: {DEVICE}, compute: {COMPUTE_TYPE}") + +# Load model once at startup +model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE) + +logger.info("Whisper model loaded successfully.") + + +class TranscribeResponse(BaseModel): + success: bool + transcript: Optional[str] = None + language: Optional[str] = None + confidence: Optional[float] = None + duration_seconds: Optional[float] = None + error: Optional[str] = None + + +@app.get("/health") +def health_check(): + return {"status": "ok", "model": MODEL_SIZE, "language": LANGUAGE} + + +@app.post("/transcribe", response_model=TranscribeResponse) +async def transcribe_audio(audio: UploadFile = File(...)): + """ + Terima fail audio, transkripkan ke teks Bahasa Melayu. + Hanya accessible dari dalam Docker network sahaja. + """ + # Validate content type + allowed_types = { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", + "audio/mp4", "audio/x-m4a", "audio/aac", "video/mp4", + "audio/ogg", "audio/flac", "audio/webm", + } + + if audio.content_type and audio.content_type not in allowed_types: + logger.warning(f"Rejected content type: {audio.content_type}") + raise HTTPException(status_code=400, detail=f"Unsupported audio format: {audio.content_type}") + + # Save to temp file + suffix = Path(audio.filename or "audio.mp3").suffix or ".mp3" + + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + content = await audio.read() + tmp.write(content) + tmp_path = tmp.name + + logger.info(f"Transcribing: {audio.filename}, size: {len(content)} bytes") + + # Run transcription + segments, info = model.transcribe( + tmp_path, + language=LANGUAGE, + beam_size=5, + vad_filter=True, + ) + + # Collect segments + all_text = [] + total_confidence = [] + + for segment in segments: + all_text.append(segment.text.strip()) + # faster-whisper segments have avg_logprob; convert to 0-1 range + if hasattr(segment, "avg_logprob") and segment.avg_logprob is not None: + import math + confidence = min(1.0, max(0.0, math.exp(segment.avg_logprob))) + total_confidence.append(confidence) + + transcript = " ".join(all_text).strip() + avg_confidence = sum(total_confidence) / len(total_confidence) if total_confidence else None + duration = info.duration if hasattr(info, "duration") else None + + logger.info(f"Transcription complete: {len(transcript)} chars, lang: {info.language}") + + return TranscribeResponse( + success=True, + transcript=transcript, + language=info.language, + confidence=round(avg_confidence, 4) if avg_confidence else None, + duration_seconds=round(duration, 2) if duration else None, + ) + + except Exception as e: + logger.error(f"Transcription failed: {e}", exc_info=True) + return TranscribeResponse( + success=False, + error=str(e), + ) + + finally: + # Always cleanup temp file + if "tmp_path" in locals(): + try: + os.unlink(tmp_path) + except Exception: + pass diff --git a/docker/transcription-worker/requirements.txt b/docker/transcription-worker/requirements.txt new file mode 100644 index 0000000..dc5bced --- /dev/null +++ b/docker/transcription-worker/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +faster-whisper==1.0.3 +python-multipart==0.0.9 +pydantic==2.8.2 +requests==2.32.3 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b1b34e3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1656 @@ +{ + "name": "speech2text", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz", + "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz", + "integrity": "sha512-Fzocl+X4eQ9jOi0RwdphYRGkUbPJ3ky1pTAST5Ot18cS2gw6d2vldK2eCrlKDVjtibCjCx5qptYDlA0373n7qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.5.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..49c869e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..c659b60 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..3e6abea --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..8337712 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1 @@ +// diff --git a/resources/views/admin/audit-logs/index.blade.php b/resources/views/admin/audit-logs/index.blade.php new file mode 100644 index 0000000..56979a0 --- /dev/null +++ b/resources/views/admin/audit-logs/index.blade.php @@ -0,0 +1,150 @@ +@extends('layouts.admin') + +@section('title', 'Log Audit') + +@section('content') +
+

Log Audit

+ + Eksport CSV + +
+ +{{-- Filter --}} +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + Padam + +
+
+
+
+ +{{-- Table --}} +
+
+
+ + + + + + + + + + + + + @forelse($logs as $log) + + + + + + + + + + {{-- Detail Modal --}} +
MasaOlehTindakanSasaranIPButiran
+ {{ $log->created_at?->format('d/m/Y H:i:s') }} + + {{ $log->actor?->name ?? 'Sistem' }} + @if($log->actor_role) + {{ $log->actor_role }} + @endif + + {{ $log->action }} + + {{ $log->targetUser?->name ?? ($log->subject_type ? class_basename($log->subject_type).'#'.$log->subject_id : '—') }} + {{ $log->ip_address ?? '—' }} + +
+ + + + + + + + @if($log->old_values) + + + + + @endif + @if($log->new_values) + + + + + @endif +
Masa{{ $log->created_at?->format('d/m/Y H:i:s') }}
Oleh{{ $log->actor?->name ?? 'Sistem' }} ({{ $log->actor_role }})
IP{{ $log->ip_address ?? '—' }}
Justifikasi{{ $log->justification ?? '—' }}
Nilai Lama
{{ json_encode($log->old_values, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}
Nilai Baru
{{ json_encode($log->new_values, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}
+
+ +
+
+ + @empty + + + Tiada rekod log. + + + @endforelse + + + + + + @if($logs->hasPages()) + + @endif + +@endsection diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php new file mode 100644 index 0000000..ddc6602 --- /dev/null +++ b/resources/views/admin/dashboard.blade.php @@ -0,0 +1,254 @@ +@extends('layouts.admin') + +@section('title', 'Dashboard') + +@section('content') +
+

Dashboard Pentadbir

+ {{ now()->format('d M Y, H:i') }} +
+ +{{-- Baris 1: Pengguna & Projek --}} +
+
+
+
+
+
+
{{ $stats['users_active'] }}
+
Pengguna Aktif
+
+ {{ $stats['users_total'] }} jumlah • + {{ $stats['users_inactive'] }} dinyahaktif +
+
+ +
+
+
+
+ +
+
+
+
+
+
{{ $stats['projects_total'] }}
+
Jumlah Projek
+
+ +{{ $stats['projects_last_7'] }} (7 hari) • + +{{ $stats['projects_last_30'] }} (30 hari) +
+
+ +
+
+
+
+ +
+
+
+
+
+
{{ $stats['projects_completed'] }}
+
Transkripsi Selesai
+ @php + $pct = $stats['projects_total'] > 0 + ? round($stats['projects_completed'] / $stats['projects_total'] * 100) + : 0; + @endphp +
+ {{ $pct }}% kadar kejayaan +
+
+ +
+
+
+
+ +
+
+
+
+
+ @php + $totalBytes = $stats['storage_bytes']; + if ($totalBytes >= 1073741824) { + $storageLabel = number_format($totalBytes / 1073741824, 2) . ' GB'; + } elseif ($totalBytes >= 1048576) { + $storageLabel = number_format($totalBytes / 1048576, 1) . ' MB'; + } else { + $storageLabel = number_format($totalBytes / 1024, 0) . ' KB'; + } + $durationH = intdiv($stats['duration_total_seconds'], 3600); + $durationM = intdiv($stats['duration_total_seconds'] % 3600, 60); + @endphp +
{{ $storageLabel }}
+
Storan Digunakan
+
+ {{ $durationH }}j {{ $durationM }}m jumlah audio +
+
+ +
+
+
+
+
+ +{{-- Baris 2: Status + Trend --}} +
+
+
+
+ Status Transkripsi +
+
+ + + @php + $statusRows = [ + ['bg-secondary', 'Menunggu', $stats['projects_pending']], + ['bg-warning text-dark', 'Sedang Diproses', $stats['projects_processing']], + ['bg-success', 'Selesai', $stats['projects_completed']], + ['bg-danger', 'Gagal', $stats['projects_failed']], + ['bg-light text-muted border', 'Dipadam (soft)', $stats['projects_deleted']], + ]; + @endphp + @foreach($statusRows as [$cls, $label, $count]) + + + + + + @endforeach + +
{{ $label }}{{ $count }} + @if($stats['projects_total'] > 0) + {{ round($count / ($stats['projects_total'] ?: 1) * 100) }}% + @endif +
+
+
+
+ +
+
+
+ Trend 30 Hari Lepas +
+
+ @if($trendRaw->isEmpty()) +
Tiada data projek dalam 30 hari lepas.
+ @else +
+ + + + + + + + + + + @foreach($trendRaw->sortByDesc('date') as $row) + + + + + + + @endforeach + +
TarikhBaruSelesaiGagal
{{ \Carbon\Carbon::parse($row->date)->format('d/m') }}{{ $row->total }}{{ $row->completed }}{{ $row->failed }}
+
+ @endif +
+
+
+ +
+
+
+ Pengguna Paling Aktif +
+
+ @if($topUsers->isEmpty()) +
Tiada data pengguna.
+ @else + + + @foreach($topUsers as $i => $u) + + + + + + @endforeach + +
{{ $i + 1 }} +
{{ $u->name }}
+
{{ $u->email }}
+
{{ $u->owned_projects_count }}
+ @endif +
+
+
+
+ +{{-- Baris 3: Jabatan + Tindakan Pantas --}} +
+
+
+
+ Projek Mengikut Jabatan +
+
+ @if($deptStats->isEmpty()) +
Tiada data jabatan.
+ @else + + + @foreach($deptStats as $dept) + + + + + @endforeach + +
{{ $dept->name }} + {{ $dept->project_count }} +
+ @endif +
+
+
+ + +
+@endsection diff --git a/resources/views/admin/projects/index.blade.php b/resources/views/admin/projects/index.blade.php new file mode 100644 index 0000000..b19511d --- /dev/null +++ b/resources/views/admin/projects/index.blade.php @@ -0,0 +1,215 @@ +@extends('layouts.admin') + +@section('title', 'Senarai Projek') + +@section('content') +
+
+

Senarai Projek

+

Metadata sahaja — kandungan audio dan teks tidak dipaparkan.

+
+ + Kembali ke Dashboard + +
+ +{{-- Filter --}} +
+
+
+
+ +
+
+ +
+
+ +
+
+ + Padam +
+
+
+
+ +{{-- Table --}} +
+
+
+ + + + + + + + + + + + + + + @php + $statusMap = [ + 'pending' => ['bg-secondary', 'Menunggu'], + 'processing' => ['bg-warning text-dark', 'Sedang Diproses'], + 'completed' => ['bg-success', 'Selesai'], + 'failed' => ['bg-danger', 'Gagal'], + ]; + @endphp + + @forelse($projects as $project) + + + + + + + + + + + @empty + + + + @endforelse + +
TajukPemilikStatusSaiz FailTempohKolaboratorTarikh CiptaTindakan
+ {{ $project->title }} + @if($project->deleted_at) + Dipadam + @endif + + {{ $project->owner->name ?? '—' }} +
{{ $project->owner->email ?? '' }}
+
+ @php [$cls, $label] = $statusMap[$project->transcription_status] ?? ['bg-secondary', $project->transcription_status]; @endphp + {{ $label }} + + @php + $bytes = $project->file_size; + echo $bytes >= 1048576 + ? number_format($bytes/1048576, 1) . ' MB' + : number_format($bytes/1024, 0) . ' KB'; + @endphp + + @if($project->duration_seconds) + {{ intdiv($project->duration_seconds, 60) }}:{{ str_pad($project->duration_seconds % 60, 2, '0', STR_PAD_LEFT) }} + @else + — + @endif + + + {{ $project->projectCollaborators->count() }} + + + {{ $project->created_at->format('d/m/Y H:i') }} + + @if(! $project->deleted_at) + + @endif +
+ Tiada projek ditemui. +
+
+
+ + @if($projects->hasPages()) + + @endif +
+ +
+ + Kandungan audio, teks transkripsi, dan ulasan projek tidak dipaparkan kepada pentadbir. +
+ +{{-- Transfer Ownership Modals --}} +@foreach($projects as $project) +@if(! $project->deleted_at) + +@endif +@endforeach + +@endsection diff --git a/resources/views/admin/users/create.blade.php b/resources/views/admin/users/create.blade.php new file mode 100644 index 0000000..0f3f862 --- /dev/null +++ b/resources/views/admin/users/create.blade.php @@ -0,0 +1,93 @@ +@extends('layouts.admin') + +@section('title', 'Daftar Pengguna Baru') + +@section('content') +
+

Daftar Pengguna Baru

+ + Kembali + +
+ +
+
+
+ @csrf + +
+
+ + + @error('name') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ + +
Minimum 8 aksara, huruf besar, huruf kecil, dan nombor.
+ @error('password') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ +
+ + + @error('role') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('department_id') +
{{ $message }}
+ @enderror +
+
+ +
+ +
+ + Batal +
+
+
+
+@endsection diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php new file mode 100644 index 0000000..9840e5f --- /dev/null +++ b/resources/views/admin/users/edit.blade.php @@ -0,0 +1,81 @@ +@extends('layouts.admin') + +@section('title', 'Kemaskini E-mel Pengguna') + +@section('content') +
+

Kemaskini E-mel Pengguna

+ + Kembali + +
+ +
+
+ {{-- User Info Card --}} +
+
Maklumat Pengguna
+
+ + + + + + + + + + + + + + + + + +
Nama{{ $user->name }}
E-mel Semasa{{ $user->email }}
Jabatan{{ $user->department?->name ?? '—' }}
Status + @if($user->is_active) + Aktif + @else + Dinyahaktif + @endif +
+
+
+ + {{-- Email Update Form --}} +
+
Kemaskini E-mel
+
+
+ @csrf @method('PATCH') + +
+ + + @error('email') +
{{ $message }}
+ @enderror +
+ +
+ + +
Perubahan ini akan direkod dalam log audit.
+
+ +
+ + Batal +
+
+
+
+
+
+@endsection diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..60757b9 --- /dev/null +++ b/resources/views/admin/users/index.blade.php @@ -0,0 +1,125 @@ +@extends('layouts.admin') + +@section('title', 'Senarai Pengguna') + +@section('content') +
+

Senarai Pengguna

+ + Daftar Pengguna Baru + +
+ +{{-- Filter --}} +
+
+
+
+ +
+
+ +
+
+ + Padam Carian +
+
+
+
+ +{{-- Table --}} +
+
+
+ + + + + + + + + + + + + @forelse($users as $user) + + + + + + + + + @empty + + + + @endforelse + +
NamaE-melJabatanStatusLog Masuk TerakhirTindakan
+ {{ $user->name }} + {{ $user->email }}{{ $user->department?->name ?? '—' }} + @if($user->is_active) + Aktif + @else + Dinyahaktif + @endif + + {{ $user->last_login_at?->format('d/m/Y H:i') ?? 'Belum pernah' }} + +
+ + + + + @if($user->is_active) +
+ @csrf @method('PATCH') + +
+ @else +
+ @csrf @method('PATCH') + +
+ @endif + + @if(! $user->hasUsage()) +
+ @csrf @method('DELETE') + +
+ @endif +
+
+ Tiada pengguna ditemui. +
+
+
+ + @if($users->hasPages()) + + @endif +
+@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..19f9259 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,103 @@ + + + + + + Log Masuk — Speech2Text MBIP + + + + + +
+
+
+ +
+
+
+ + + + diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php new file mode 100644 index 0000000..130ddf0 --- /dev/null +++ b/resources/views/layouts/admin.blade.php @@ -0,0 +1,128 @@ + + + + + + + Admin — @yield('title', 'Pentadbir') — MBIP + + + + + + + @stack('styles') + + + {{-- Navbar --}} + + +
+
+ {{-- Sidebar --}} + + + {{-- Main Content --}} +
+ @if(session('success')) + + @endif + + @if(session('error')) + + @endif + + @if($errors->any()) + + @endif + + @yield('content') +
+
+
+ + + + + @stack('scripts') + + diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..f1cb3f8 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,118 @@ + + + + + + + @yield('title', 'Speech2Text') — MBIP + + + + + + + @stack('styles') + + + {{-- Navbar --}} + + +
+
+ {{-- Sidebar --}} + + + {{-- Main Content --}} +
+ @if(session('success')) + + @endif + + @if(session('error')) + + @endif + + @yield('content') +
+
+
+ + + + + @stack('scripts') + + diff --git a/resources/views/user/dashboard.blade.php b/resources/views/user/dashboard.blade.php new file mode 100644 index 0000000..f2a11da --- /dev/null +++ b/resources/views/user/dashboard.blade.php @@ -0,0 +1,117 @@ +@extends('layouts.app') + +@section('title', 'Dashboard') + +@section('content') +
+

Dashboard

+ + Projek Baru + +
+ +{{-- Owned Projects --}} +
+
+ Projek Saya + {{ $ownedProjects->total() }} +
+
+
+ + + + + + + + + + + + + @forelse($ownedProjects as $project) + + + + + + + + + @empty + + + + @endforelse + +
TajukStatusSaiz FailTempohTarikh Cipta
{{ $project->title }} + @php $statusClasses = ['pending'=>'secondary','processing'=>'warning','completed'=>'success','failed'=>'danger']; @endphp + + {{ ucfirst($project->transcription_status) }} + + {{ $project->fileSizeForHumans() }}{{ $project->durationForHumans() ?? '—' }}{{ $project->created_at->format('d/m/Y') }} + + + +
+ +

Belum ada projek transkripsi.

+ + Muat Naik Audio Pertama + +
+
+
+ @if($ownedProjects->hasPages()) + + @endif +
+ +{{-- Shared Projects --}} +@if($sharedProjects->count() > 0) +
+
+ Projek Dikongsi Bersama Saya + {{ $sharedProjects->total() }} +
+
+
+ + + + + + + + + + + + @foreach($sharedProjects as $project) + + + + + + + + @endforeach + +
TajukPemilikStatusTarikh Cipta
{{ $project->title }}{{ $project->owner->name }} + + {{ ucfirst($project->transcription_status) }} + + {{ $project->created_at->format('d/m/Y') }} + + + +
+
+
+ @if($sharedProjects->hasPages()) + + @endif +
+@endif +@endsection diff --git a/resources/views/user/projects/create.blade.php b/resources/views/user/projects/create.blade.php new file mode 100644 index 0000000..eac3cf5 --- /dev/null +++ b/resources/views/user/projects/create.blade.php @@ -0,0 +1,118 @@ +@extends('layouts.app') + +@section('title', 'Projek Baru') + +@section('content') +
+

Cipta Projek Transkripsi Baru

+ + Kembali + +
+ +
+
+
+
+
+ @csrf + +
+ + + @error('title') +
{{ $message }}
+ @enderror +
+ +
+ + + @error('description') +
{{ $message }}
+ @enderror +
+ +
+ + +
+ +
+ + +
+ Format disokong: MP3, WAV, M4A, MP4, AAC, OGG, FLAC, WEBM. + Saiz maksimum: {{ config('speech2text.upload.max_mb') }}MB. +
+ @error('audio') +
{{ $message }}
+ @enderror + + {{-- Preview nama fail yang dipilih --}} +
+ + + + — + +
+
+ +
+ + Batal +
+
+
+
+
+ +
+
+
+
Maklumat
+
    +
  • Audio akan diproses secara automatik dalam barisan antrian.
  • +
  • Masa pemprosesan bergantung pada panjang audio dan saiz model.
  • +
  • Anda boleh menutup halaman ini — pemprosesan akan terus berjalan.
  • +
  • Notifikasi status akan dikemaskini dalam halaman projek.
  • +
  • Fail audio disimpan secara selamat dan tidak boleh diakses umum.
  • +
+
+
+
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/user/projects/show.blade.php b/resources/views/user/projects/show.blade.php new file mode 100644 index 0000000..b4ca4ee --- /dev/null +++ b/resources/views/user/projects/show.blade.php @@ -0,0 +1,465 @@ +@extends('layouts.app') + +@section('title', $project->title) + +@section('content') + +{{-- Header --}} +
+
+

{{ $project->title }}

+ @if($project->description) +

{{ $project->description }}

+ @endif +
+
+ + Dashboard + + @if($project->isOwnedBy(auth()->user())) +
+ @csrf @method('DELETE') + +
+ @endif +
+
+ +
+ + {{-- Lajur Kiri: Audio + Transcript + Komen --}} +
+ + {{-- Status Badge --}} + @php + $statusConfig = [ + 'pending' => ['secondary', 'bi-hourglass', 'Menunggu dalam barisan...'], + 'processing' => ['warning', 'bi-gear-wide-connected', 'Sedang ditranskripkan...'], + 'completed' => ['success', 'bi-check-circle', 'Transkripsi selesai'], + 'failed' => ['danger', 'bi-x-circle', 'Transkripsi gagal'], + ]; + [$sc, $si, $sl] = $statusConfig[$project->transcription_status] ?? ['secondary', 'bi-question', 'Tidak diketahui']; + @endphp + +
+ {{ $sl }} + @if($project->isFailed() && $project->isOwnedBy(auth()->user())) +
+ @csrf + +
+ @endif + @if($project->isProcessing() || $project->isPending()) + + @endif +
+ + {{-- Audio Player --}} +
+
+ Audio Asal + {{ $project->original_filename }} +
+
+ +
+ {{ $project->fileSizeForHumans() }} + @if($project->durationForHumans()) +  • {{ $project->durationForHumans() }} + @endif +  • {{ $project->created_at->format('d/m/Y H:i') }} +
+
+
+ + {{-- Transcript Card --}} +
+
+ Teks Transkripsi +
+ @if($project->transcriptVersions->count() > 0) + + Sejarah Versi + {{ $project->transcriptVersions->count() }} + + @endif + @can('editTranscript', $project) + @if($project->isCompleted() && $project->transcript_text) + + @endif + @endcan +
+
+ +
+ + {{-- Read mode --}} +
+ @if($project->isCompleted() && $project->transcript_text) +
{{ $project->transcript_text }}
+ @if($project->transcript_confidence) +
+ + Keyakinan: {{ number_format($project->transcript_confidence * 100, 1) }}% +  • Enjin: {{ $project->transcription_engine ?? '—' }} +
+ @endif + @elseif($project->isFailed()) +
+ + {{ $project->error_message ?? 'Ralat tidak diketahui.' }} +
+ @else +

+ + Teks transkripsi akan dipaparkan di sini setelah pemprosesan selesai. +

+ @endif +
+ + {{-- Edit mode (hidden by default) --}} + @can('editTranscript', $project) + + @endcan + +
+ + {{-- Card footer: Upload transkripsi luar --}} + @can('editTranscript', $project) + + @endcan + +
+ + {{-- Komen --}} + @can('view', [App\Models\ProjectComment::class, $project]) +
+
+ Perbincangan + @if($project->comments->count()) + {{ $project->comments->count() }} + @endif +
+
+ + {{-- Senarai komen --}} + @forelse($project->comments as $comment) +
+
+ + {{ mb_strtoupper(mb_substr($comment->user->name, 0, 1)) }} + +
+
+
+ {{ $comment->user->name }} +
+ {{ $comment->created_at->format('d/m/Y H:i') }} + @can('delete', $comment) +
+ @csrf @method('DELETE') + +
+ @endcan +
+
+
{{ $comment->message }}
+
+
+ @empty +

Tiada perbincangan lagi.

+ @endforelse + + {{-- Form tambah komen --}} + @can('create', [App\Models\ProjectComment::class, $project]) +
+
+ @csrf +
+ + @error('message') +
{{ $message }}
+ @enderror +
+ +
+ @endcan + +
+
+ @endcan + +
+ + {{-- Lajur Kanan: Maklumat + Collaborators --}} +
+ + {{-- Project Info --}} +
+
+ Maklumat Projek +
+
+ + + + + + + + + + + + + + @if($project->processed_at) + + + + + @endif +
Pemilik{{ $project->owner->name }}
Bahasa{{ strtoupper($project->language) }}
Enjin{{ $project->transcription_engine ?? '—' }}
Selesai pada{{ $project->processed_at->format('d/m/Y H:i') }}
+
+
+ + {{-- Collaborators --}} +
+
+ Collaborators +
+
+ + @forelse($project->collaborators as $collab) +
+
+ {{ $collab->name }} + {{ $collab->pivot->role }} +
{{ $collab->email }}
+
+ @if($project->isOwnedBy(auth()->user())) +
+ @csrf @method('DELETE') + +
+ @endif +
+ @empty +

Tiada collaborator.

+ @endforelse + + @if($project->isOwnedBy(auth()->user())) +
+
+ @csrf +
+ + +
+ @error('email') +
{{ $message }}
+ @enderror +
+ +
+
+ @endif + +
+
+ +
+
+ +{{-- Modal: Upload Transkripsi Luar --}} +@can('editTranscript', $project) + +@endcan + +@endsection + +@push('styles') + +@endpush + +@push('scripts') + +{{-- AJAX status polling --}} +@if($project->isPending() || $project->isProcessing()) + +@endif + +{{-- Upload transcript modal: confirm if already has transcript --}} + + +{{-- Transcript editor toggle --}} + + +@endpush diff --git a/resources/views/user/projects/versions.blade.php b/resources/views/user/projects/versions.blade.php new file mode 100644 index 0000000..189c80e --- /dev/null +++ b/resources/views/user/projects/versions.blade.php @@ -0,0 +1,109 @@ +@extends('layouts.app') + +@section('title', 'Sejarah Versi — ' . $project->title) + +@section('content') + +
+
+
Sejarah Versi
+

{{ $project->title }}

+
+ + Kembali ke Projek + +
+ +@if($versions->isEmpty()) +
+ Tiada sejarah versi untuk projek ini. +
+@else +
+
+
+ + + + + + + + + + + + @foreach($versions as $version) + + + + + + + + @endforeach + +
VersiPenyuntingRingkasan PerubahanTarikhTindakan
+ v{{ $version->version_number }} + {{ $version->editor->name ?? '—' }}{{ $version->change_summary ?? '—' }}{{ $version->created_at->format('d/m/Y H:i') }} +
+ + @can('restoreVersion', $project) +
+ @csrf + +
+ @endcan +
+
+
+
+
+ +{{ $versions->links() }} + +{{-- Modals: preview versi --}} +@foreach($versions as $version) + +@endforeach + +@endif + +@endsection diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..26e294a --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

With so many options available to you,
we suggest you start with the following:

+ + + +

+ v{{ app()->version() }} + + View changelog + + + + +

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..8af5aa5 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,114 @@ +check()) { + return auth()->user()->isAdmin() + ? redirect()->route('admin.dashboard') + : redirect()->route('user.dashboard'); + } + return redirect()->route('login'); +}); + +// ============================================================ +// Authentication +// ============================================================ +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('/login', [AuthenticatedSessionController::class, 'store']) + ->middleware('throttle:login'); +}); + +Route::post('/logout', [AuthenticatedSessionController::class, 'destroy']) + ->middleware('auth') + ->name('logout'); + +// ============================================================ +// Admin Routes — /admin/* +// ============================================================ +Route::middleware(['auth', 'active', 'admin']) + ->prefix('admin') + ->name('admin.') + ->group(function () { + + Route::get('/dashboard', [AdminDashboardController::class, 'index'])->name('dashboard'); + + // User Management + Route::prefix('users')->name('users.')->group(function () { + Route::get('/', [AdminUserController::class, 'index'])->name('index'); + Route::get('/create', [AdminUserController::class, 'create'])->name('create'); + Route::post('/', [AdminUserController::class, 'store'])->name('store'); + Route::get('/{user}/edit', [AdminUserController::class, 'edit'])->name('edit'); + Route::patch('/{user}/email', [AdminUserController::class, 'updateEmail'])->name('update-email'); + Route::patch('/{user}/activate', [AdminUserController::class, 'activate'])->name('activate'); + Route::patch('/{user}/deactivate', [AdminUserController::class, 'deactivate'])->name('deactivate'); + Route::delete('/{user}', [AdminUserController::class, 'destroy'])->name('destroy'); + }); + + // Project Metadata (metadata sahaja — tiada kandungan) + Route::get('/projects', [ProjectMetadataController::class, 'index'])->name('projects.index'); + Route::post('/projects/{project}/transfer-owner', [ProjectMetadataController::class, 'transferOwner'])->name('projects.transfer-owner'); + + // Audit Logs + Route::get('/audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index'); + Route::get('/audit-logs/export', [AuditLogController::class, 'export'])->name('audit-logs.export'); + }); + +// ============================================================ +// User Routes — /dashboard & /projects +// ============================================================ +Route::middleware(['auth', 'active']) + ->name('user.') + ->group(function () { + + Route::get('/dashboard', [UserDashboardController::class, 'index'])->name('dashboard'); + + // Projects + Route::prefix('projects')->name('projects.')->group(function () { + Route::get('/create', [ProjectController::class, 'create'])->name('create'); + Route::post('/', [ProjectController::class, 'store'])->name('store')->middleware('throttle:upload'); + Route::get('/{project:uuid}', [ProjectController::class, 'show'])->name('show'); + Route::delete('/{project:uuid}', [ProjectController::class, 'destroy'])->name('destroy'); + + // Status polling (JSON) + Route::get('/{project:uuid}/status', [ProjectController::class, 'status'])->name('status'); + + // Retry transcription + Route::post('/{project:uuid}/retry', [ProjectController::class, 'retry'])->name('retry'); + + // Audio stream (melalui controller — bukan direct URL) + Route::get('/{project:uuid}/audio', [AudioController::class, 'stream'])->name('audio.stream'); + + // Transcript editor + Route::patch('/{project:uuid}/transcript', [TranscriptController::class, 'update'])->name('transcript.update'); + Route::post('/{project:uuid}/transcript/upload', [TranscriptController::class, 'uploadExternal'])->name('transcript.upload'); + + // Version history + Route::get('/{project:uuid}/versions', [TranscriptVersionController::class, 'index'])->name('versions.index'); + Route::post('/{project:uuid}/versions/{version}/restore', [TranscriptVersionController::class, 'restore'])->name('versions.restore'); + + // Comments + Route::post('/{project:uuid}/comments', [CommentController::class, 'store'])->name('comments.store'); + Route::delete('/{project:uuid}/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy'); + + // Collaborators + Route::post('/{project:uuid}/collaborators', [CollaboratorController::class, 'store'])->name('collaborators.store'); + Route::delete('/{project:uuid}/collaborators/{user}', [CollaboratorController::class, 'destroy'])->name('collaborators.destroy'); + }); + }); diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100644 index 0000000..2764420 --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Backup MySQL sebelum sebarang update production +# Guna: ./scripts/backup-db.sh + +set -e + +BACKUP_DIR="/svr/speech2text/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/speech2text_$TIMESTAMP.sql.gz" + +mkdir -p "$BACKUP_DIR" + +echo "Membuat backup database..." +docker compose exec -T mysql mysqldump \ + -u root -p"$MYSQL_ROOT_PASSWORD" \ + --single-transaction \ + --quick \ + speech2text | gzip > "$BACKUP_FILE" + +echo "Backup selesai: $BACKUP_FILE" +echo "Saiz: $(du -h $BACKUP_FILE | cut -f1)" + +# Padam backup lebih 30 hari +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete +echo "Backup lama (>30 hari) dipadam." diff --git a/speech2text b/speech2text new file mode 100644 index 0000000000000000000000000000000000000000..f627c90f60cd00225a6993733233622f7aa9c570 GIT binary patch literal 176128 zcmeI)?Qa}M9l-Is#P&H!ooi{sQcCD%)5KnqK%7!V6#@}b3t5yv+eA7UD&FJHOrc#&`u;Utk8#9ri&5H!&b8h;CQHvQBY&!|E5()Wgd2Irl_PVlTYECD78yO)yDclGYOvC7%30Q69E&I~ zQ|84FLoYKmY**5I_I{1Q0*~fmVy>|3m=- z2q1s}0tg_000IagfB*usFTnHv+3#aohyVfzAbfB*srAb{oU3tbD5o2KG8A6+4fH82R(6r)Aa|U=iCW5V#f=EKzNBC z2F^QUF&0jq=qAo&-si8sxpwX5nz?@CwY6`XR#D3A>(fF6{ucz^dTznEbgCVv12-Mr z*^n;ZpQ?}G$t;!`2BxKsTIPzCo@?2=?Poj2sZ;F-B5^kcLZzzj=klU%Ui-@HYt>19 z&occW5q%MvqsSk+(YAS4Y@6;lk#mn1iJ=G*GYIAPcrdtV=2tPd+{nA_MtaJ~-5!Ll zEH4rFYp1wLB8HegvTANpKdnr*fAq1A zu_mX_v#F%#dbfo$2)(7Hkd#zT=ZPg@3bWC)rp-ibvnk?tL@zvQi_oq z$M?dh=cs8eagy+^2;#@IYSRXH*{>2#sGOwPmVa?UvL)vBeAbn^D!y0QMo zt@56AZ{{ov)8iF~{r>s+bo76CA!SdK(;o|U*7Cf^UiSibC})L+Sk2(3rZ`(7@;Ci_ zrK}=Vh^}C8`Kj<;p2CKANuV4=$wv zs^!sM&eeleODX2c;s#1zm8agSO%2(!N=3vzZXi5Hr`q2`&%bi+-& z+vS)l2cf;R_>0zw)_bj!KR>y-^vYB3EiTXhd;VK1}Lr7qb^$26Gtsa*}cf&Zhj(Wa%)CHW_|A9;`D74hxINxW)LVjk`t9C!>WE%9x^0sB~MXTw17^`Ww-UG5j zlW}zeWEKlUbFOru7>S_g2Yt0v$gY>ePVP3-cV0Lgr4z17%03%vUPr1DCTb%B30<|J zR$E7H0?S^j>Zd-x+Vp#B0EqGq?-RnnP!nB||$ zRSA2}$(EEVL%O1r6ZQOr>3+vpIFMx zMOEh)r(SgOoTdv`dE_d3?uCx=J=xs1C)(x2;|(>frGvG6Q|xo9s(gBCRrW2Qsb2aC zmW;CEz1latf9{j%D)r&1bOx@jU%fh=*ChWurb>9CRZ8}b96u^jC+_jGgxc+-tZUwx zO;cXcjM}VMZ(cFm$)kxI_2s^%;lf(CY5BuJ&q+7fauUl2c_8jh9=bD*6M4_vPV-;m@J+I%579Y^7kNMSdnct0Shd-GI zO?{K3Wzf&df>ujZk(DmPcB^&t&mZ+&2jwAvz+n(L(5HuLa(dV^$i|tcI!UdT*~1F? zBA=h+r~mfuXN`1^{QlW=)zoG#?d2!x>!z0SL<{HCZHZRhre!j#e^biT&mYBoPo#UW zY(*9l`ZmZ@#&9#h9L<*3Au@ttwsK z|K#@;`US!izPjn~T)6=msE6wH$4>gKL(^saz>jZFo~3^nlWh}h{{O$jFcDG*1Q0*~ z0R#|0009ILKmY**4pxAF|9`N)l#T!b2q1s}0tg_000IagfWTo8VE=y@dVxA1fB*sr zAbp{|`eiPzMAMKmY** z5I_I{1Q0*~0R#?KfdBvRV0|ea0R#|0009ILKmY**5I_Kd!yv%(|HIG=)Bynm5I_I{ z1Q0*~0R#|00D*%QVE=!xzLbst0tg_000IagfB*srAb`MO5Mcj*7REt~|gO0tg_000K`W@ZidF$GCK< z{eh9V8v`+l!aKrC94{OWMUcdk?=M_`bM4yAHS^}Rue`oyT9e6^xnlV}%k+ap^hIQj zB7f*c+vZ)dZMx$m^aC#vs$eq+<@b0nxL6gUpQt5|V-Y!1(uN|A-M+BQMBJ~HDKFzD zqUX4YW%lH*TIsqsj?*>kuj24e5w%`g&vKTwrlDO)NlVw;iJ6p;u#5t~*Lxk2nj z{wVRoz{x>kD|N8VFfg;iP0x)zwMtN#uKpz4MLp9V33Zy*uv* zJ=yZI3eJ{1QPv|aDk9;EV@@z0ZYatrr`UPduHSfV?b~|0iUJ;5sHK{#fs`kzuqZ-B z0?Lla>zUWdvLAf^T*r9%<@N{tGz=SB)Np8}9*hk+4k8{!&sVXsv0a6^7OfKvD@THs zB^>k|6|Y&;N{m3<(+=oE!@KPUec{C8p&CY(x#dRkP$bW34)9uRY2x+ps-T`EWUKbq zOMPh2e(j}>arbI_x1B~{Q9Caj4BU+{a+5HsMOZ#GoN?g4Gu9?1E&FmF0Jm6* zF4Ma$yt{J5W=H+NTv=9q9!AR-&E+j$+!N8VZI@vxC%8>_JV?wcm*w_)(}zfN=uVlN z{~Hlq&^%H+H_zi`gCD7kY9e2^I!5?vd*|N7tgH`Ar#@ce#7)NWV9n*kL8M#4e5F@`9O$YY8=zec^$2^hgDi-m6iOw z*)d#M$?xu0$?#qv7s_G++9&7PN0M$R?qRj9onqPi{{Iu*+;S2E2q1s}0tg_000Iag LfB*vjQv&}1$h=KM literal 0 HcmV?d00001 diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 0000000..05c4471 --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,9 @@ +compiled.php +config.php +down +events.scanned.php +maintenance.php +routes.php +routes.scanned.php +schedule-* +services.json diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/testing/.gitignore b/storage/framework/testing/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/testing/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/Admin/Phase5Test.php b/tests/Feature/Admin/Phase5Test.php new file mode 100644 index 0000000..af43a22 --- /dev/null +++ b/tests/Feature/Admin/Phase5Test.php @@ -0,0 +1,257 @@ +admin()->create(); + } + + private function makeUser(): User + { + return User::factory()->create(); + } + + private function makeProject(User $owner, array $attrs = []): TranscriptionProject + { + return TranscriptionProject::factory()->create(array_merge([ + 'owner_user_id' => $owner->id, + ], $attrs)); + } + + // ------------------------------------------------------- + // Dashboard + // ------------------------------------------------------- + + public function test_admin_dashboard_loads_with_full_stats(): void + { + $admin = $this->makeAdmin(); + + $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertOk() + ->assertViewIs('admin.dashboard') + ->assertViewHas('stats') + ->assertViewHas('topUsers') + ->assertViewHas('trendRaw') + ->assertViewHas('deptStats'); + } + + public function test_user_cannot_access_admin_dashboard(): void + { + $user = $this->makeUser(); + + $this->actingAs($user) + ->get(route('admin.dashboard')) + ->assertForbidden(); + } + + public function test_dashboard_shows_storage_stats(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + + TranscriptionProject::factory()->create([ + 'owner_user_id' => $owner->id, + 'file_size' => 10485760, // 10 MB + ]); + + $response = $this->actingAs($admin) + ->get(route('admin.dashboard')) + ->assertOk(); + + $stats = $response->viewData('stats'); + $this->assertGreaterThanOrEqual(10485760, $stats['storage_bytes']); + } + + // ------------------------------------------------------- + // Transfer Ownership + // ------------------------------------------------------- + + public function test_admin_can_transfer_project_ownership(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + $newOwner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $newOwner->id, + 'justification' => 'Pekerja telah berpindah jabatan dan projek perlu dipindahkan.', + ])->assertRedirect(route('admin.projects.index')); + + $project->refresh(); + $this->assertEquals($newOwner->id, $project->owner_user_id); + } + + public function test_transfer_ownership_creates_audit_log(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + $newOwner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $newOwner->id, + 'justification' => 'Sebab pemindahan yang sah dan lengkap.', + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'project_ownership_transferred', + 'project_id' => $project->id, + 'target_user_id' => $newOwner->id, + ]); + + $log = AuditLog::where('action', 'project_ownership_transferred')->first(); + $this->assertStringContainsString('Sebab pemindahan', $log->justification); + } + + public function test_user_cannot_transfer_ownership(): void + { + $user = $this->makeUser(); + $owner = $this->makeUser(); + $newOwner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($user) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $newOwner->id, + 'justification' => 'Cuba pindah oleh pengguna biasa.', + ])->assertForbidden(); + + $project->refresh(); + $this->assertEquals($owner->id, $project->owner_user_id); + } + + public function test_transfer_requires_justification(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + $newOwner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $newOwner->id, + 'justification' => 'Singkat', // less than 10 chars + ])->assertSessionHasErrors('justification'); + + $project->refresh(); + $this->assertEquals($owner->id, $project->owner_user_id); + } + + public function test_transfer_requires_valid_new_owner(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => 99999, + 'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.', + ])->assertSessionHasErrors('new_owner_id'); + } + + public function test_cannot_transfer_to_admin_user(): void + { + $admin = $this->makeAdmin(); + $admin2 = $this->makeAdmin(); + $owner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $admin2->id, + 'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.', + ])->assertForbidden(); + } + + public function test_cannot_transfer_to_same_owner(): void + { + $admin = $this->makeAdmin(); + $owner = $this->makeUser(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('admin.projects.transfer-owner', $project), [ + 'new_owner_id' => $owner->id, + 'justification' => 'Justifikasi yang sah untuk pemindahan projek ini.', + ])->assertStatus(422); + } + + // ------------------------------------------------------- + // Audit Log Export + // ------------------------------------------------------- + + public function test_admin_can_export_audit_log_csv(): void + { + $admin = $this->makeAdmin(); + + AuditLog::create([ + 'actor_user_id' => $admin->id, + 'actor_role' => 'admin', + 'action' => 'user_created', + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + ]); + + $response = $this->actingAs($admin) + ->get(route('admin.audit-logs.export')); + + $response->assertOk(); + $this->assertStringContainsString('text/csv', $response->headers->get('Content-Type')); + $this->assertStringContainsString('audit-log-', $response->headers->get('Content-Disposition')); + } + + public function test_user_cannot_export_audit_log(): void + { + $user = $this->makeUser(); + + $this->actingAs($user) + ->get(route('admin.audit-logs.export')) + ->assertForbidden(); + } + + public function test_audit_log_export_respects_filters(): void + { + $admin = $this->makeAdmin(); + + AuditLog::create([ + 'actor_user_id' => $admin->id, + 'actor_role' => 'admin', + 'action' => 'user_activated', + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + ]); + + AuditLog::create([ + 'actor_user_id' => $admin->id, + 'actor_role' => 'admin', + 'action' => 'user_deactivated', + 'ip_address' => '127.0.0.1', + 'created_at' => now(), + ]); + + $response = $this->actingAs($admin) + ->get(route('admin.audit-logs.export', ['action' => 'user_activated'])); + + $response->assertOk(); + $content = $response->streamedContent(); + $this->assertStringContainsString('user_activated', $content); + $this->assertStringNotContainsString('user_deactivated', $content); + } +} diff --git a/tests/Feature/Admin/UserManagementTest.php b/tests/Feature/Admin/UserManagementTest.php new file mode 100644 index 0000000..30a5d5d --- /dev/null +++ b/tests/Feature/Admin/UserManagementTest.php @@ -0,0 +1,166 @@ +admin = User::factory()->admin()->create(); + } + + // ------------------------------------------------------- + // Access Control + // ------------------------------------------------------- + + public function test_regular_user_cannot_access_admin_user_list(): void + { + $user = User::factory()->create(); + $this->actingAs($user)->get(route('admin.users.index'))->assertForbidden(); + } + + public function test_admin_can_view_user_list(): void + { + $this->actingAs($this->admin) + ->get(route('admin.users.index')) + ->assertOk() + ->assertViewIs('admin.users.index'); + } + + // ------------------------------------------------------- + // Create User + // ------------------------------------------------------- + + public function test_admin_can_create_user(): void + { + $this->actingAs($this->admin) + ->post(route('admin.users.store'), [ + 'name' => 'Pengguna Baru', + 'email' => 'baru@mbip.my', + 'password' => 'Password@123', + 'password_confirmation' => 'Password@123', + 'role' => 'user', + 'department_id' => null, + ])->assertRedirect(route('admin.users.index')); + + $this->assertDatabaseHas('users', ['email' => 'baru@mbip.my', 'role' => 'user']); + $this->assertDatabaseHas('audit_logs', ['action' => 'user_created']); + } + + // ------------------------------------------------------- + // Deactivate / Activate + // ------------------------------------------------------- + + public function test_admin_can_deactivate_user(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->patch(route('admin.users.deactivate', $user)) + ->assertRedirect(route('admin.users.index')); + + $this->assertFalse($user->fresh()->is_active); + $this->assertDatabaseHas('audit_logs', ['action' => 'user_deactivated', 'target_user_id' => $user->id]); + } + + public function test_admin_can_activate_user(): void + { + $user = User::factory()->inactive()->create(); + + $this->actingAs($this->admin) + ->patch(route('admin.users.activate', $user)) + ->assertRedirect(route('admin.users.index')); + + $this->assertTrue($user->fresh()->is_active); + $this->assertDatabaseHas('audit_logs', ['action' => 'user_reactivated']); + } + + public function test_deactivated_user_cannot_login(): void + { + $user = User::factory()->inactive()->create(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertSessionHasErrors('email'); + + $this->assertGuest(); + } + + // ------------------------------------------------------- + // Email Change + // ------------------------------------------------------- + + public function test_admin_can_change_user_email(): void + { + $user = User::factory()->create(['email' => 'lama@mbip.my']); + + $this->actingAs($this->admin) + ->patch(route('admin.users.update-email', $user), [ + 'email' => 'baru@mbip.my', + 'justification' => 'Pertukaran emel rasmi', + ])->assertRedirect(route('admin.users.index')); + + $this->assertEquals('baru@mbip.my', $user->fresh()->email); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'user_email_changed', + 'target_user_id' => $user->id, + ]); + } + + // ------------------------------------------------------- + // Delete + // ------------------------------------------------------- + + public function test_admin_can_delete_user_without_usage(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->delete(route('admin.users.destroy', $user)) + ->assertRedirect(route('admin.users.index')); + + $this->assertNull(User::find($user->id)); + $this->assertDatabaseHas('audit_logs', ['action' => 'user_deleted']); + } + + public function test_admin_cannot_delete_user_with_usage(): void + { + $user = User::factory()->create(); + + // Cipta audit log untuk simulasi usage + AuditLog::create([ + 'actor_user_id' => $user->id, + 'actor_role' => 'user', + 'action' => 'project_created', + 'created_at' => now(), + ]); + + $this->actingAs($this->admin) + ->delete(route('admin.users.destroy', $user)) + ->assertForbidden(); + + $this->assertNotNull(User::find($user->id)); + } + + // ------------------------------------------------------- + // Admin cannot access own account + // ------------------------------------------------------- + + public function test_admin_cannot_deactivate_self(): void + { + $this->actingAs($this->admin) + ->patch(route('admin.users.deactivate', $this->admin)) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Auth/LoginTest.php b/tests/Feature/Auth/LoginTest.php new file mode 100644 index 0000000..dd223eb --- /dev/null +++ b/tests/Feature/Auth/LoginTest.php @@ -0,0 +1,68 @@ +get(route('login'))->assertOk()->assertViewIs('auth.login'); + } + + public function test_admin_redirected_to_admin_dashboard(): void + { + $admin = User::factory()->admin()->create(); + + $this->post(route('login'), [ + 'email' => $admin->email, + 'password' => 'password', + ])->assertRedirect(route('admin.dashboard')); + } + + public function test_user_redirected_to_user_dashboard(): void + { + $user = User::factory()->create(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(route('user.dashboard')); + } + + public function test_deactivated_user_cannot_login(): void + { + $user = User::factory()->inactive()->create(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertSessionHasErrors('email'); + } + + public function test_wrong_password_fails(): void + { + $user = User::factory()->create(); + + $this->post(route('login'), [ + 'email' => $user->email, + 'password' => 'wrong-password', + ])->assertSessionHasErrors('email'); + } + + public function test_logout_works(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('logout')) + ->assertRedirect(route('login')); + + $this->assertGuest(); + } +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..7287e11 --- /dev/null +++ b/tests/Feature/ExampleTest.php @@ -0,0 +1,13 @@ +get('/')->assertRedirect(route('login')); + } +} diff --git a/tests/Feature/Project/ProjectAccessTest.php b/tests/Feature/Project/ProjectAccessTest.php new file mode 100644 index 0000000..2c4d229 --- /dev/null +++ b/tests/Feature/Project/ProjectAccessTest.php @@ -0,0 +1,245 @@ +create(array_merge([ + 'owner_user_id' => $owner->id, + ], $attrs)); + } + + // ------------------------------------------------------- + // Admin tidak boleh akses kandungan projek + // ------------------------------------------------------- + + public function test_admin_cannot_view_project_detail(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->get(route('user.projects.show', $project)) + ->assertForbidden(); + } + + public function test_admin_cannot_stream_audio(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->get(route('user.projects.audio.stream', $project)) + ->assertForbidden(); + } + + public function test_admin_can_view_project_metadata_list(): void + { + $admin = User::factory()->admin()->create(); + + $this->actingAs($admin) + ->get(route('admin.projects.index')) + ->assertOk() + ->assertViewIs('admin.projects.index'); + } + + // ------------------------------------------------------- + // Owner boleh akses projek sendiri + // ------------------------------------------------------- + + public function test_owner_can_view_own_project(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($owner) + ->get(route('user.projects.show', $project)) + ->assertOk() + ->assertViewIs('user.projects.show'); + } + + // ------------------------------------------------------- + // Collaborator boleh akses projek yang dikongsi + // ------------------------------------------------------- + + public function test_collaborator_can_view_shared_project(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $project = $this->makeProject($owner); + + ProjectCollaborator::create([ + 'project_id' => $project->id, + 'user_id' => $collab->id, + 'role' => 'editor', + 'added_by' => $owner->id, + ]); + + $this->actingAs($collab) + ->get(route('user.projects.show', $project)) + ->assertOk(); + } + + // ------------------------------------------------------- + // Bukan collaborator tidak boleh akses + // ------------------------------------------------------- + + public function test_non_collaborator_cannot_view_project(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($other) + ->get(route('user.projects.show', $project)) + ->assertForbidden(); + } + + // ------------------------------------------------------- + // Hanya owner boleh delete projek + // ------------------------------------------------------- + + public function test_only_owner_can_delete_project(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $project = $this->makeProject($owner); + + ProjectCollaborator::create([ + 'project_id' => $project->id, + 'user_id' => $collab->id, + 'role' => 'editor', + 'added_by' => $owner->id, + ]); + + // Collaborator tidak boleh delete + $this->actingAs($collab) + ->delete(route('user.projects.destroy', $project)) + ->assertForbidden(); + + // Owner boleh delete + $this->actingAs($owner) + ->delete(route('user.projects.destroy', $project)) + ->assertRedirect(route('user.dashboard')); + + $this->assertSoftDeleted('transcription_projects', ['id' => $project->id]); + } + + // ------------------------------------------------------- + // Upload audio dan dispatch job + // ------------------------------------------------------- + + public function test_user_can_upload_audio_and_job_dispatched(): void + { + Queue::fake(); + Storage::fake('private'); + + $user = User::factory()->create(); + + // Create a temp file with valid MPEG-1 L3 frame sync bytes (FF FB) + $tmpPath = tempnam(sys_get_temp_dir(), 'mp3_'); + file_put_contents($tmpPath, "\xFF\xFB\x90\x00" . str_repeat("\xFF", 1024)); + $file = new UploadedFile($tmpPath, 'rakaman.mp3', 'audio/mpeg', null, true); + + $this->actingAs($user) + ->post(route('user.projects.store'), [ + 'title' => 'Mesyuarat Mac 2025', + 'description' => 'Rakaman mesyuarat bulanan', + 'audio' => $file, + 'language' => 'ms', + ])->assertRedirect(); + + $project = TranscriptionProject::where('title', 'Mesyuarat Mac 2025')->first(); + $this->assertNotNull($project); + $this->assertEquals('pending', $project->transcription_status); + $this->assertEquals($user->id, $project->owner_user_id); + + // Fail disimpan dalam private storage (bukan public) + Storage::disk('private')->assertExists($project->stored_audio_path); + + // Job dihantar ke queue + Queue::assertPushed(\App\Jobs\TranscribeAudioJob::class); + + @unlink($tmpPath); + } + + // ------------------------------------------------------- + // Admin — metadata list tidak dedahkan transcript + // ------------------------------------------------------- + + public function test_admin_metadata_list_does_not_expose_transcript(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + + TranscriptionProject::factory()->create([ + 'owner_user_id' => $owner->id, + 'transcript_text' => 'Ini adalah teks rahsia yang sensitif.', + 'transcription_status' => 'completed', + ]); + + $response = $this->actingAs($admin) + ->get(route('admin.projects.index')) + ->assertOk(); + + $response->assertDontSee('Ini adalah teks rahsia yang sensitif.'); + } + + // ------------------------------------------------------- + // Collaborator management + // ------------------------------------------------------- + + public function test_owner_can_add_collaborator(): void + { + $owner = User::factory()->create(); + $newUser = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($owner) + ->post(route('user.projects.collaborators.store', $project), [ + 'email' => $newUser->email, + 'role' => 'editor', + ])->assertRedirect(); + + $this->assertDatabaseHas('project_collaborators', [ + 'project_id' => $project->id, + 'user_id' => $newUser->id, + 'role' => 'editor', + ]); + } + + public function test_non_owner_cannot_add_collaborator(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner); + + ProjectCollaborator::create([ + 'project_id' => $project->id, + 'user_id' => $collab->id, + 'role' => 'editor', + 'added_by' => $owner->id, + ]); + + $this->actingAs($collab) + ->post(route('user.projects.collaborators.store', $project), [ + 'email' => $other->email, + ])->assertForbidden(); + } +} diff --git a/tests/Feature/Project/TranscriptEditorTest.php b/tests/Feature/Project/TranscriptEditorTest.php new file mode 100644 index 0000000..614bde3 --- /dev/null +++ b/tests/Feature/Project/TranscriptEditorTest.php @@ -0,0 +1,463 @@ +create(array_merge([ + 'owner_user_id' => $owner->id, + 'transcription_status' => 'completed', + 'transcript_text' => 'Teks asal transkripsi.', + ], $attrs)); + } + + private function addCollaborator(TranscriptionProject $project, User $user, string $role = 'editor'): void + { + ProjectCollaborator::create([ + 'project_id' => $project->id, + 'user_id' => $user->id, + 'role' => $role, + 'added_by' => $project->owner_user_id, + ]); + } + + // ------------------------------------------------------- + // Edit transcript (PATCH) + // ------------------------------------------------------- + + public function test_owner_can_edit_transcript(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($owner) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Teks baru selepas diedit.', + 'change_summary' => 'Perbaikan ejaan', + ])->assertRedirect(); + + $project->refresh(); + $this->assertEquals('Teks baru selepas diedit.', $project->transcript_text); + + $this->assertDatabaseHas('transcript_versions', [ + 'project_id' => $project->id, + 'version_number' => 1, + 'change_summary' => 'Perbaikan ejaan', + ]); + } + + public function test_editor_collaborator_can_edit_transcript(): void + { + $owner = User::factory()->create(); + $editor = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $editor, 'editor'); + + $this->actingAs($editor) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Diedit oleh editor.', + ])->assertRedirect(); + + $project->refresh(); + $this->assertEquals('Diedit oleh editor.', $project->transcript_text); + } + + public function test_viewer_collaborator_cannot_edit_transcript(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $viewer, 'viewer'); + + $this->actingAs($viewer) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Cuba edit oleh viewer.', + ])->assertForbidden(); + } + + public function test_admin_cannot_edit_transcript(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Admin cuba edit.', + ])->assertForbidden(); + } + + public function test_non_collaborator_cannot_edit_transcript(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($other) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Cubaan haram.', + ])->assertForbidden(); + } + + public function test_edit_creates_version_history(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcript_text' => 'Asal.']); + + $this->actingAs($owner) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Kemaskini pertama.', + ]); + + $this->actingAs($owner) + ->patch(route('user.projects.transcript.update', $project), [ + 'transcript_text' => 'Kemaskini kedua.', + ]); + + $this->assertEquals(2, TranscriptVersion::where('project_id', $project->id)->count()); + + $v1 = TranscriptVersion::where('project_id', $project->id)->where('version_number', 1)->first(); + $v2 = TranscriptVersion::where('project_id', $project->id)->where('version_number', 2)->first(); + + $this->assertEquals('Asal.', $v1->old_text); + $this->assertEquals('Kemaskini pertama.', $v1->new_text); + $this->assertEquals('Kemaskini pertama.', $v2->old_text); + $this->assertEquals('Kemaskini kedua.', $v2->new_text); + } + + // ------------------------------------------------------- + // Upload transkripsi luar (.txt) + // ------------------------------------------------------- + + public function test_owner_can_upload_external_transcript(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcript_text' => null, 'transcription_status' => 'failed']); + + $file = UploadedFile::fake()->createWithContent('transkripsi.txt', "Ini teks dari luar sistem.\nBaris kedua."); + + $this->actingAs($owner) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + ])->assertRedirect(); + + $project->refresh(); + $this->assertEquals('completed', $project->transcription_status); + $this->assertEquals('manual', $project->transcription_engine); + $this->assertStringContainsString('Ini teks dari luar sistem.', $project->transcript_text); + + $this->assertDatabaseHas('transcript_versions', [ + 'project_id' => $project->id, + 'version_number' => 1, + 'change_summary' => 'Transkripsi dimuat naik secara manual', + ]); + } + + public function test_upload_external_replaces_existing_transcript(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcript_text' => 'Teks whisper asal.']); + + $file = UploadedFile::fake()->createWithContent('baru.txt', 'Teks baru dari sumber luar.'); + + $this->actingAs($owner) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + ])->assertRedirect(); + + $project->refresh(); + $this->assertEquals('Teks baru dari sumber luar.', $project->transcript_text); + + // Versi lama tersimpan + $v = TranscriptVersion::where('project_id', $project->id)->first(); + $this->assertEquals('Teks whisper asal.', $v->old_text); + } + + public function test_upload_external_dispatches_ollama_when_checkbox_checked(): void + { + Queue::fake(); + + config(['speech2text.ollama.enabled' => true]); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $file = UploadedFile::fake()->createWithContent('t.txt', 'Teks untuk dibersihkan.'); + + $this->actingAs($owner) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + 'clean_with_ollama' => '1', + ])->assertRedirect(); + + Queue::assertPushed(OllamaPostProcessJob::class); + } + + public function test_upload_external_does_not_dispatch_ollama_without_checkbox(): void + { + Queue::fake(); + + config(['speech2text.ollama.enabled' => true]); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $file = UploadedFile::fake()->createWithContent('t.txt', 'Teks biasa.'); + + $this->actingAs($owner) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + ])->assertRedirect(); + + Queue::assertNotPushed(OllamaPostProcessJob::class); + } + + public function test_viewer_cannot_upload_external_transcript(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $viewer, 'viewer'); + + $file = UploadedFile::fake()->createWithContent('t.txt', 'Cuba upload.'); + + $this->actingAs($viewer) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + ])->assertForbidden(); + } + + public function test_upload_rejects_non_txt_file(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $file = UploadedFile::fake()->create('teks.docx', 50, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + + $this->actingAs($owner) + ->post(route('user.projects.transcript.upload', $project), [ + 'transcript_file' => $file, + ])->assertSessionHasErrors('transcript_file'); + } + + // ------------------------------------------------------- + // Version history & restore + // ------------------------------------------------------- + + public function test_owner_can_view_version_history(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => $owner->id, + 'version_number' => 1, + 'old_text' => 'Lama.', + 'new_text' => 'Baru.', + 'change_summary' => 'Edit pertama', + 'created_at' => now(), + ]); + + $this->actingAs($owner) + ->get(route('user.projects.versions.index', $project)) + ->assertOk() + ->assertViewIs('user.projects.versions') + ->assertSee('Edit pertama'); + } + + public function test_admin_cannot_view_version_history(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->get(route('user.projects.versions.index', $project)) + ->assertForbidden(); + } + + public function test_owner_can_restore_version(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcript_text' => 'Teks semasa.']); + + $version = TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => $owner->id, + 'version_number' => 1, + 'old_text' => 'Sebelum edit.', + 'new_text' => 'Teks versi 1.', + 'created_at' => now(), + ]); + + $this->actingAs($owner) + ->post(route('user.projects.versions.restore', [$project, $version])) + ->assertRedirect(route('user.projects.show', $project)); + + $project->refresh(); + $this->assertEquals('Teks versi 1.', $project->transcript_text); + + // Pemulihan cipta versi baru + $this->assertDatabaseHas('transcript_versions', [ + 'project_id' => $project->id, + 'version_number' => 2, + 'change_summary' => 'Dipulihkan dari versi 1', + ]); + } + + public function test_viewer_cannot_restore_version(): void + { + $owner = User::factory()->create(); + $viewer = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $viewer, 'viewer'); + + $version = TranscriptVersion::create([ + 'project_id' => $project->id, + 'edited_by' => $owner->id, + 'version_number' => 1, + 'old_text' => null, + 'new_text' => 'Versi 1.', + 'created_at' => now(), + ]); + + $this->actingAs($viewer) + ->post(route('user.projects.versions.restore', [$project, $version])) + ->assertForbidden(); + } + + // ------------------------------------------------------- + // Comments + // ------------------------------------------------------- + + public function test_owner_can_add_comment(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($owner) + ->post(route('user.projects.comments.store', $project), [ + 'message' => 'Ini komen pertama saya.', + ])->assertRedirect(); + + $this->assertDatabaseHas('project_comments', [ + 'project_id' => $project->id, + 'user_id' => $owner->id, + 'message' => 'Ini komen pertama saya.', + ]); + } + + public function test_collaborator_can_add_comment(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $collab, 'viewer'); + + $this->actingAs($collab) + ->post(route('user.projects.comments.store', $project), [ + 'message' => 'Komen daripada viewer.', + ])->assertRedirect(); + + $this->assertDatabaseHas('project_comments', [ + 'project_id' => $project->id, + 'user_id' => $collab->id, + ]); + } + + public function test_non_collaborator_cannot_add_comment(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($other) + ->post(route('user.projects.comments.store', $project), [ + 'message' => 'Cubaan haram.', + ])->assertForbidden(); + } + + public function test_admin_cannot_add_comment(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->post(route('user.projects.comments.store', $project), [ + 'message' => 'Admin cuba komen.', + ])->assertForbidden(); + } + + public function test_owner_can_delete_any_comment_in_project(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $collab); + + $comment = $project->comments()->create([ + 'user_id' => $collab->id, + 'message' => 'Komen collaborator.', + ]); + + $this->actingAs($owner) + ->delete(route('user.projects.comments.destroy', [$project, $comment])) + ->assertRedirect(); + + $this->assertSoftDeleted('project_comments', ['id' => $comment->id]); + } + + public function test_comment_author_can_delete_own_comment(): void + { + $owner = User::factory()->create(); + $collab = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $collab); + + $comment = $project->comments()->create([ + 'user_id' => $collab->id, + 'message' => 'Komen saya sendiri.', + ]); + + $this->actingAs($collab) + ->delete(route('user.projects.comments.destroy', [$project, $comment])) + ->assertRedirect(); + + $this->assertSoftDeleted('project_comments', ['id' => $comment->id]); + } + + public function test_non_author_cannot_delete_others_comment(): void + { + $owner = User::factory()->create(); + $collab1 = User::factory()->create(); + $collab2 = User::factory()->create(); + $project = $this->makeProject($owner); + $this->addCollaborator($project, $collab1); + $this->addCollaborator($project, $collab2); + + $comment = $project->comments()->create([ + 'user_id' => $collab1->id, + 'message' => 'Milik collab1.', + ]); + + $this->actingAs($collab2) + ->delete(route('user.projects.comments.destroy', [$project, $comment])) + ->assertForbidden(); + } +} diff --git a/tests/Feature/Project/TranscriptionJobTest.php b/tests/Feature/Project/TranscriptionJobTest.php new file mode 100644 index 0000000..b4ecbe3 --- /dev/null +++ b/tests/Feature/Project/TranscriptionJobTest.php @@ -0,0 +1,397 @@ +create(array_merge([ + 'owner_user_id' => $owner->id, + ], $attrs)); + } + + // ------------------------------------------------------- + // Status endpoint (JSON polling) + // ------------------------------------------------------- + + public function test_owner_can_poll_status(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcription_status' => 'processing']); + + $this->actingAs($owner) + ->getJson(route('user.projects.status', $project)) + ->assertOk() + ->assertJsonFragment(['status' => 'processing']); + } + + public function test_non_owner_cannot_poll_status(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($other) + ->getJson(route('user.projects.status', $project)) + ->assertForbidden(); + } + + public function test_admin_cannot_poll_status(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner); + + $this->actingAs($admin) + ->getJson(route('user.projects.status', $project)) + ->assertForbidden(); + } + + public function test_status_returns_error_message_only_when_failed(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'transcription_status' => 'failed', + 'error_message' => 'Connection refused', + ]); + + $data = $this->actingAs($owner) + ->getJson(route('user.projects.status', $project)) + ->assertOk() + ->json(); + + $this->assertEquals('failed', $data['status']); + $this->assertEquals('Connection refused', $data['error_message']); + } + + public function test_status_hides_error_message_when_not_failed(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcription_status' => 'processing']); + + $data = $this->actingAs($owner) + ->getJson(route('user.projects.status', $project)) + ->assertOk() + ->json(); + + $this->assertNull($data['error_message']); + } + + // ------------------------------------------------------- + // Retry endpoint + // ------------------------------------------------------- + + public function test_owner_can_retry_failed_transcription(): void + { + Queue::fake(); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'transcription_status' => 'failed', + 'error_message' => 'timeout', + ]); + + $this->actingAs($owner) + ->post(route('user.projects.retry', $project)) + ->assertRedirect(); + + $project->refresh(); + $this->assertEquals('pending', $project->transcription_status); + $this->assertNull($project->error_message); + + Queue::assertPushed(TranscribeAudioJob::class); + } + + public function test_non_owner_cannot_retry(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + $project = $this->makeProject($owner, ['transcription_status' => 'failed']); + + $this->actingAs($other) + ->post(route('user.projects.retry', $project)) + ->assertForbidden(); + } + + public function test_retry_not_allowed_when_not_failed(): void + { + Queue::fake(); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcription_status' => 'completed']); + + $this->actingAs($owner) + ->post(route('user.projects.retry', $project)) + ->assertForbidden(); + + Queue::assertNotPushed(TranscribeAudioJob::class); + } + + public function test_admin_cannot_retry(): void + { + $admin = User::factory()->admin()->create(); + $owner = User::factory()->create(); + $project = $this->makeProject($owner, ['transcription_status' => 'failed']); + + $this->actingAs($admin) + ->post(route('user.projects.retry', $project)) + ->assertForbidden(); + } + + // ------------------------------------------------------- + // TranscribeAudioJob — mock worker HTTP call + // ------------------------------------------------------- + + public function test_transcribe_job_marks_completed_on_success(): void + { + Storage::fake('private'); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + // Put a fake file so exists() passes + Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes'); + + Http::fake([ + '*/transcribe' => Http::response([ + 'success' => true, + 'transcript' => 'Salam sejahtera.', + 'confidence' => 0.92, + 'duration_seconds' => 45.3, + ], 200), + ]); + + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + $project->refresh(); + $this->assertEquals('completed', $project->transcription_status); + $this->assertEquals('Salam sejahtera.', $project->transcript_text); + $this->assertNotNull($project->processed_at); + } + + public function test_transcribe_job_marks_failed_on_worker_error(): void + { + Storage::fake('private'); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes'); + + Http::fake([ + '*/transcribe' => Http::response(['success' => false, 'error' => 'OOM'], 200), + ]); + + // Job catches the exception internally, marks project failed, then calls $this->fail($e). + // When invoked directly (not via queue worker), fail() does not re-throw. + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + $project->refresh(); + $this->assertEquals('failed', $project->transcription_status); + $this->assertStringContainsString('OOM', $project->error_message); + } + + public function test_transcribe_job_marks_failed_on_http_error(): void + { + Storage::fake('private'); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes'); + + Http::fake([ + '*/transcribe' => Http::response([], 500), + ]); + + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + $project->refresh(); + $this->assertEquals('failed', $project->transcription_status); + $this->assertStringContainsString('500', $project->error_message); + } + + public function test_transcribe_job_dispatches_ollama_job_when_enabled(): void + { + Queue::fake(); + Storage::fake('private'); + + config(['speech2text.ollama.enabled' => true]); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes'); + + Http::fake([ + '*/transcribe' => Http::response([ + 'success' => true, + 'transcript' => 'Terima kasih.', + 'confidence' => 0.88, + 'duration_seconds' => 10.0, + ], 200), + ]); + + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + Queue::assertPushed(OllamaPostProcessJob::class); + } + + public function test_transcribe_job_does_not_dispatch_ollama_when_disabled(): void + { + Queue::fake(); + Storage::fake('private'); + + config(['speech2text.ollama.enabled' => false]); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + Storage::disk('private')->put($project->stored_audio_path, 'fake-audio-bytes'); + + Http::fake([ + '*/transcribe' => Http::response([ + 'success' => true, + 'transcript' => 'Terima kasih.', + 'confidence' => 0.88, + 'duration_seconds' => 10.0, + ], 200), + ]); + + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + Queue::assertNotPushed(OllamaPostProcessJob::class); + } + + // ------------------------------------------------------- + // OllamaPostProcessJob + // ------------------------------------------------------- + + public function test_ollama_job_updates_transcript_when_available(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'transcription_status' => 'completed', + 'transcript_text' => 'teks mentah daripada whisper', + ]); + + $mockOllama = $this->createMock(OllamaService::class); + $mockOllama->method('isAvailable')->willReturn(true); + $mockOllama->method('cleanTranscript')->willReturn('Teks bersih daripada Ollama.'); + + (new OllamaPostProcessJob($project))->handle($mockOllama); + + $project->refresh(); + $this->assertEquals('Teks bersih daripada Ollama.', $project->transcript_text); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'transcript_postprocessed', + 'project_id' => $project->id, + ]); + } + + public function test_ollama_job_skips_when_unavailable(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'transcription_status' => 'completed', + 'transcript_text' => 'teks asal', + ]); + + $mockOllama = $this->createMock(OllamaService::class); + $mockOllama->method('isAvailable')->willReturn(false); + $mockOllama->expects($this->never())->method('cleanTranscript'); + + (new OllamaPostProcessJob($project))->handle($mockOllama); + + $project->refresh(); + $this->assertEquals('teks asal', $project->transcript_text); + } + + public function test_ollama_job_skips_when_project_not_completed(): void + { + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'transcription_status' => 'failed', + 'transcript_text' => null, + ]); + + $mockOllama = $this->createMock(OllamaService::class); + $mockOllama->expects($this->never())->method('isAvailable'); + $mockOllama->expects($this->never())->method('cleanTranscript'); + + (new OllamaPostProcessJob($project))->handle($mockOllama); + } + + // ------------------------------------------------------- + // Audit log ditulis oleh job + // ------------------------------------------------------- + + public function test_audit_log_written_on_transcription_completed(): void + { + Storage::fake('private'); + + $owner = User::factory()->create(); + $project = $this->makeProject($owner, [ + 'stored_audio_path' => 'transcriptions/test-uuid/audio/audio.mp3', + 'mime_type' => 'audio/mpeg', + 'transcription_status' => 'pending', + ]); + + Storage::disk('private')->put($project->stored_audio_path, 'bytes'); + + Http::fake([ + '*/transcribe' => Http::response([ + 'success' => true, + 'transcript' => 'Log audit.', + 'confidence' => 0.9, + 'duration_seconds' => 5.0, + ], 200), + ]); + + (new TranscribeAudioJob($project))->handle(app(StorageService::class)); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'transcription_completed', + 'project_id' => $project->id, + 'actor_role' => 'system', + ]); + } +} diff --git a/tests/Feature/Security/Phase6Test.php b/tests/Feature/Security/Phase6Test.php new file mode 100644 index 0000000..e7020d7 --- /dev/null +++ b/tests/Feature/Security/Phase6Test.php @@ -0,0 +1,265 @@ +" . str_repeat("X", 512)); + return [$tmpPath, new UploadedFile($tmpPath, $name, 'audio/mpeg', null, true)]; + } + + // ------------------------------------------------------- + // Security Headers + // ------------------------------------------------------- + + public function test_security_headers_present_on_web_response(): void + { + $response = $this->get(route('login')); + $response->assertOk(); + + $response->assertHeader('X-Frame-Options', 'SAMEORIGIN'); + $response->assertHeader('X-Content-Type-Options', 'nosniff'); + $response->assertHeader('X-XSS-Protection', '1; mode=block'); + $response->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + } + + public function test_csp_and_permissions_headers_present(): void + { + $response = $this->get(route('login')); + $response->assertOk(); + + $this->assertNotEmpty($response->headers->get('Content-Security-Policy')); + $this->assertNotEmpty($response->headers->get('Permissions-Policy')); + } + + public function test_security_headers_present_on_authenticated_response(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('user.dashboard')); + $response->assertOk(); + + $response->assertHeader('X-Frame-Options', 'SAMEORIGIN'); + $response->assertHeader('X-Content-Type-Options', 'nosniff'); + } + + public function test_csp_blocks_framing_from_other_origins(): void + { + $csp = $this->get(route('login'))->headers->get('Content-Security-Policy'); + + $this->assertStringContainsString("frame-ancestors", $csp === null ? '' : $csp); + } + + // ------------------------------------------------------- + // Magic Bytes Validation + // ------------------------------------------------------- + + public function test_upload_accepts_valid_mp3_magic_bytes(): void + { + Storage::fake('private'); + Queue::fake(); + + $user = User::factory()->create(); + [$tmpPath, $file] = $this->makeMp3File(); + + $this->actingAs($user) + ->post(route('user.projects.store'), [ + 'title' => 'Audio MP3 Sah', + 'audio' => $file, + ])->assertRedirect(); + + Queue::assertPushed(\App\Jobs\TranscribeAudioJob::class); + + @unlink($tmpPath); + } + + public function test_upload_accepts_valid_wav_magic_bytes(): void + { + Storage::fake('private'); + Queue::fake(); + + $user = User::factory()->create(); + [$tmpPath, $file] = $this->makeWavFile(); + + $this->actingAs($user) + ->post(route('user.projects.store'), [ + 'title' => 'Audio WAV Sah', + 'audio' => $file, + ])->assertRedirect(); + + @unlink($tmpPath); + } + + public function test_upload_rejects_file_with_invalid_magic_bytes(): void + { + Storage::fake('private'); + Queue::fake(); + + $user = User::factory()->create(); + [$tmpPath, $file] = $this->makeInvalidFile(); + + $this->actingAs($user) + ->post(route('user.projects.store'), [ + 'title' => 'Fail Palsu', + 'audio' => $file, + ])->assertSessionHasErrors('audio'); + + Queue::assertNothingPushed(); + Storage::disk('private')->assertDirectoryEmpty('transcriptions'); + + @unlink($tmpPath); + } + + public function test_upload_rejects_polyglot_file_with_valid_extension_wrong_content(): void + { + Storage::fake('private'); + Queue::fake(); + + $user = User::factory()->create(); + + $tmpPath = tempnam(sys_get_temp_dir(), 'poly_'); + // HTML disguised as MP3 + file_put_contents($tmpPath, 'XSS' . str_repeat('X', 512)); + $file = new UploadedFile($tmpPath, 'audio.mp3', 'audio/mpeg', null, true); + + $this->actingAs($user) + ->post(route('user.projects.store'), [ + 'title' => 'Polyglot', + 'audio' => $file, + ])->assertSessionHasErrors('audio'); + + Queue::assertNothingPushed(); + + @unlink($tmpPath); + } + + // ------------------------------------------------------- + // Rate Limiting — Login (5 per minute) + // ------------------------------------------------------- + + public function test_login_throttled_after_5_failed_attempts(): void + { + User::factory()->create(['email' => 'pengguna@test.com']); + + for ($i = 0; $i < 5; $i++) { + $this->post('/login', [ + 'email' => 'pengguna@test.com', + 'password' => 'kata-laluan-salah', + ]); + } + + $this->post('/login', [ + 'email' => 'pengguna@test.com', + 'password' => 'kata-laluan-salah', + ])->assertStatus(429); + } + + public function test_login_allows_successful_login_within_limit(): void + { + $user = User::factory()->create([ + 'email' => 'sah@test.com', + 'password' => bcrypt('kata-laluan-betul'), + ]); + + // 2 failed attempts + $this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']); + $this->post('/login', ['email' => 'sah@test.com', 'password' => 'salah']); + + // Successful login still works under limit + $this->post('/login', [ + 'email' => 'sah@test.com', + 'password' => 'kata-laluan-betul', + ])->assertRedirect(); + } + + // ------------------------------------------------------- + // Rate Limiting — Upload (10 per hour) + // ------------------------------------------------------- + + public function test_upload_throttled_after_10_requests(): void + { + Storage::fake('private'); + Queue::fake(); + + $user = User::factory()->create(); + + // 10 requests — will fail validation (no valid magic bytes) but throttle still counts + for ($i = 0; $i < 10; $i++) { + $this->actingAs($user)->post(route('user.projects.store'), [ + 'title' => "Projek {$i}", + 'audio' => UploadedFile::fake()->create("audio-{$i}.mp3", 1, 'audio/mpeg'), + ]); + } + + // 11th request must be throttled regardless of payload + $this->actingAs($user)->post(route('user.projects.store'), [ + 'title' => 'Projek 11', + 'audio' => UploadedFile::fake()->create('audio.mp3', 1, 'audio/mpeg'), + ])->assertStatus(429); + } + + public function test_upload_throttle_is_per_user(): void + { + Storage::fake('private'); + Queue::fake(); + + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + // Exhaust userA's quota + for ($i = 0; $i < 10; $i++) { + $this->actingAs($userA)->post(route('user.projects.store'), [ + 'title' => "A-{$i}", + 'audio' => UploadedFile::fake()->create("a-{$i}.mp3", 1, 'audio/mpeg'), + ]); + } + + $this->actingAs($userA)->post(route('user.projects.store'), [ + 'title' => 'A-11', + 'audio' => UploadedFile::fake()->create('a.mp3', 1, 'audio/mpeg'), + ])->assertStatus(429); + + // userB's quota is independent — not throttled + [$tmpPath, $file] = $this->makeMp3File('b.mp3'); + $this->actingAs($userB)->post(route('user.projects.store'), [ + 'title' => 'B-1', + 'audio' => $file, + ])->assertRedirect(); + + @unlink($tmpPath); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..1fd66d5 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import { bunny } from 'laravel-vite-plugin/fonts'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + fonts: [ + bunny('Instrument Sans', { + weights: [400, 500, 600], + }), + ], + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});