diff --git a/.DS_Store b/.DS_Store index 90f1afb..9151e7d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index 94cba16..6efbf0b 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ APP_NAME="Thế Giới Số" APP_ENV=local -APP_KEY=base64:sYMyAlCRrCeWx98l2YZqmH6wH8boVHiD2Wf6PVro9To= +APP_KEY= +APP_PREVIOUS_KEYS= APP_DEBUG=false APP_URL=https://dev.tgs.com.vn APP_LOCALE=vi @@ -9,32 +10,35 @@ LOG_CHANNEL=stack LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug - DB_CONNECTION=mysql DB_HOST=localhost DB_PORT=3306 DB_DATABASE=portal_dev DB_USERNAME=portal_dev -DB_PASSWORD="admin@123!@#dev" +DB_PASSWORD= #Config Custom SignRSA -RSA_KEY=RXfUFoCkxItIZXffLxyEJe8EhDhCz0Nq -RSA_PUBLIC="-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDhm9x8DFf8D4pVBgPi6N4o0Aw7\nfX90vxjAQ+ekRIVu/+5qWw/mMHbaHtVNuxmU8es1v3iN0k3QPqxNqZuXLYWWzxFR\n5XGpC9GKF1BBZxzDPV5HS4MI9kHwjAWh4XdatEEW/V4pSdZPBR5bP4PcJq+SE+4m\nKiR7HLvR6Yd3Il5AfwIDAQAB\n-----END PUBLIC KEY-----" -RSA_PRIVATE="-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQDhm9x8DFf8D4pVBgPi6N4o0Aw7fX90vxjAQ+ekRIVu/+5qWw/m\nMHbaHtVNuxmU8es1v3iN0k3QPqxNqZuXLYWWzxFR5XGpC9GKF1BBZxzDPV5HS4MI\n9kHwjAWh4XdatEEW/V4pSdZPBR5bP4PcJq+SE+4mKiR7HLvR6Yd3Il5AfwIDAQAB\nAoGAJcHH7JrEPtL6+6++BsOiKoJnCa2/HvK8WbEDm2I8Uo9Sf0ouJwAFZAu09wVR\nCrRSAPTCYDnJ/AgBHh48g9iCeqJE4U8coaXe2WXrR+Ys/UTxY1A6K7+RWzqbYNSL\n/Dnjf2vZmPNM0B2n/R9E5O2UBu2cHsBxhtSVJQwyQeo458ECQQD2x8yn6P2+g+P5\nyh22VjoxIE2FA2FDEuZPIARQaJWtOgqmMVb+/Id+/S34sRxXXoDF4vT9kIfKnrI6\nSBkcA271AkEA6gmTtjTP13fdpeknk+fg80CVd2LOf4dLr5Qfh2wdNhaz2Rymq9At\nO0Abvegf6l6X5ThHGeYgXZkSNYTfgeahIwJAPcT9APpcqRcyNQ/WKBGlNy6yuJyD\n99n/M8b7zXSgIl/o0nh/Igvrjmmo3BrY0TWiXeXVFLLcimb2jYgGYPCrEQJBANBa\nsFdRUwvnWbuAA80obVsCT06W93IPmDD69MrSVHs34OtUF4n0bqBGJax/ABLeHepi\nnOIQvxW01cIxxe3WzfMCQQCQWZGu78Oov+SeI2a64PukYmZXkvI85UdSMKvT4Ey/\nHASBA/VPYQlhScznIMKHNwXpIqYqInjh/1QvHZ/0h0jf\n-----END RSA PRIVATE KEY-----" - -#Config PASSPORT -PASSPORT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1IW8ZeL+cwbatI1sQwt2zEmWwI2ovityTTz3/O+WGXgD85Zz\nEOA0thy91mJMNZv5qyWPexbmROrYpUSe9mQl4HIDlMipuWE+pJImmrJGGCf15QdO\nOVQvoknwVxr3lfSGwEFx2ubPMb1asytawbzQ/pZc6F7VP8gqcdVpI0JnolDtXa/v\niaURTIvYY1WtwPjRa4m98fin4aKMh3sDt88W00RaMjPn00aW8NRo8s/Oa0d/DXEI\ne08RGGUBHuuqRhzB4wHYnpafBqpZDp2LYHvdw+7Vf4dPh63jfBy6ISv+eGSIFuSE\npuiumDt4J7ZjCjh+RDavJQhfGGiG+kzMZ4iAZwIDAQABAoIBACME0nF7gPAVTg54\nS0GU7wNkkNmzvf1wDUqa5EYieHpD42rM/LLoLudfPwzHb8VZqT9BVi4zLMhoZkB9\nWTBCeldMkjh/BDCOzJKznnbZL2GlnAgcNxCNRbgZNOg1glyMSXP+lLM8WyHYEMz7\nNN49rbZ1smN71OnIou3U+5kaxRbir8RNh93fp5yt519a7gRybL8Td2FH64lcSRuX\nHVDtwX95d9m8tY8WoxQPz7jzztUlfNWgRgQhBSYXpOjAEWoJaXnOYo2mxoR6pm9p\npqgkgGvwmfv0Mi97anFoPMymM3HzUFOqqtr5Q7M6DXC+1CSmtkw6hvpED/IV6RJh\nOQtG6hECgYEA/fdnE6m7NMJe4jZxq+mB7+4HYvHLodl8Vb0ncIHwbhAiqaoQNUj2\nfzotHdBeligiives4S3N10RwvKNb1M0iJs41GmfTLcJRoUvyfHbYWLX+M5FRdQ4q\nvihFj8ckontcdXUSoqdZXz0KcVMkHq1hnXt7CayfvXwTeFDdfPoevUkCgYEA1jlg\n6pYg3xGAGNPF5FGiVEMp+BT7h6w41fRmL7c7LFkksUkXmbOIY+M/NLzx2mNUUXvL\nOfMjFUtjr2kehApS6/v7pgJjqqcmFYQJAMnwsVmuBVDw7+OCxK7IrR24jwYLpVMd\nzOiWALZMlLD1bnP0v6sa0hb5/ag8bFElpV+FwC8CgYEAromI3YqYjfnHG9Au7ubb\n1fjQOr/SIrlFKeeFY1zpz7pnGBM4cGZkgauJtiOt3aRXmQn+VgMUpAz1La9XPTh9\ncA3nkO4pxBj34NJBJCjJJq5stSP5x2ilF2q148oI82G0eUSsOfyo/zsEIj3xZOPj\nYkeql0t7vM2408c/cNS0q4ECgYEArDX8HxkI28npvAuiLeUiJbLIblIP0gU0u2p5\nKyqVOxV+VbZ4bmrQ3du01+R8WB85jTeDK9a1AU1VmZFborHZ7zLmHZIWJP2xs+Is\niqHMCAYbytJCndW7LfmEAQa/ctpdSk4CytK+o1vpPC71XhGBUwNt0C4AUVWJHVou\ndT7Q+qkCgYAE0QGNls/EUcCFoX6LUXDAQZFe46rp5hxcGOrjdjbgZl5DiJSrnEvH\n1sh908Q1FchJagA+nUVOm+nbOV3EMf6XlHRyB9XtRcSgEfls9O0HWUi/67YJig9L\ncElLxwyrZpLdzMRYz0JknXSb7GnnsZPG4HG5rhMMz/Ly7lb6f7GZ1w==\n-----END RSA PRIVATE KEY-----" -PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1IW8ZeL+cwbatI1sQwt2\nzEmWwI2ovityTTz3/O+WGXgD85ZzEOA0thy91mJMNZv5qyWPexbmROrYpUSe9mQl\n4HIDlMipuWE+pJImmrJGGCf15QdOOVQvoknwVxr3lfSGwEFx2ubPMb1asytawbzQ\n/pZc6F7VP8gqcdVpI0JnolDtXa/viaURTIvYY1WtwPjRa4m98fin4aKMh3sDt88W\n00RaMjPn00aW8NRo8s/Oa0d/DXEIe08RGGUBHuuqRhzB4wHYnpafBqpZDp2LYHvd\nw+7Vf4dPh63jfBy6ISv+eGSIFuSEpuiumDt4J7ZjCjh+RDavJQhfGGiG+kzMZ4iA\nZwIDAQAB\n-----END PUBLIC KEY-----" +RSA_KEY= +RSA_PUBLIC= +RSA_PRIVATE= +RSA_PREVIOUS_KEY= +RSA_PREVIOUS_PUBLIC= #Config VMware template default credential -VMWARE_TEMPLATE_PASSWORD=change-me +VMWARE_TEMPLATE_PASSWORD= + +#Config vCenter/ESXi TLS + jump host SSH verification (see config/vmware.php) +VCENTER_TLS_VERIFY=true +VCENTER_CA_BUNDLE= +VCENTER_SSH_KNOWN_HOSTS= + +#Config Plesk API TLS verification (see config/services.php) +PLESK_TLS_VERIFY=true +PLESK_CA_BUNDLE= #CONFIG CKFINDER CKFINDER_DOMAIN=client.test -CKFINDER_LICENSE=WS8SXEQQXPN8MYTF51SX1VFFUW3B6 - -PASSPORT_PERSONAL_ACCESS_CLIENT_ID=9c1e5bea-4c4e-4c86-8a29-91e7444d3897 -PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET=T3TGImDGDT0p2I9wD4ckunLZV9iHyJbl378s83HQ +CKFINDER_LICENSE= BROADCAST_DRIVER=pusher CACHE_DRIVER=redis @@ -46,18 +50,18 @@ SESSION_LIFETIME=240 MEMCACHED_HOST=127.0.0.1 REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=Redis@9999 +REDIS_PASSWORD= REDIS_PORT=6379 REDIS_CLIENT=predis -TELEGRAM_BOT_ID=6972012759 -TELEGRAM_BOT_TOKEN=8022195470:AAFU_cv79UAzSWvPW_tiYA21dCD9oNnTRus +TELEGRAM_BOT_ID= +TELEGRAM_BOT_TOKEN= MAIL_MAILER=smtp MAIL_HOST=smtp.gmail.com MAIL_PORT=465 -MAIL_USERNAME=pbao.business@gmail.com -MAIL_PASSWORD=uusxfksjtcorivqh +MAIL_USERNAME= +MAIL_PASSWORD= MAIL_ENCRYPTION=tls MAIL_FROM_ADDRESS=noreply@yourdomain.com @@ -67,16 +71,16 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false -PUSHER_APP_ID=2158258 -PUSHER_APP_KEY=08113c904934c0bc7581 -PUSHER_APP_SECRET=8402ee6ff613513a1dad +PUSHER_APP_ID= +PUSHER_APP_KEY= +PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=ap1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" -VNPAY_TMN_CODE=BU4VVGV5 -VNPAY_HASH_SECRET=ISDSSDEGJ8EKDHXJVTVMD919DAFBF96I +VNPAY_TMN_CODE= +VNPAY_HASH_SECRET= VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html VNPAY_RETURN_URL=https://api-agency.test/v1/transaction/return VNPAY_API_URL=http://sandbox.vnpayment.vn/merchant_webapi/merchant.html @@ -85,8 +89,6 @@ FRONTEND_URL=https://portal.test # openssl rand -hex 32 CLIENT_API_ACCESS_KEY= -CLIENT_API_ACCESS_SECRET="tgs-client-api" - - +CLIENT_API_ACCESS_SECRET= WMKS_HOST=10.10.10.120 \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 319cc94..144ec1e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,11 +12,12 @@ jobs: steps: - name: Deploy - uses: appleboy/ssh-action@v1.2.0 + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} - password: ${{ secrets.PASSWORD }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + fingerprint: ${{ secrets.SSH_HOST_FINGERPRINT }} port: ${{ secrets.PORT }} script_stop: true script: | @@ -58,9 +59,9 @@ jobs: exit 1 fi - composer install + composer install --no-dev --optimize-autoloader - composer dump-autoload + composer dump-autoload --no-dev --optimize php artisan migrate --force diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..7d1478d --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,21 @@ +name: Secret scan + +on: + push: + pull_request: + +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Scan new commits + env: + RANGE: ${{ github.event_name == 'pull_request' && format('{0}..{1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('{0}..{1}', github.event.before, github.sha) }} + run: | + case "$RANGE" in 0000000000000000000000000000000000000000..*) RANGE="${GITHUB_SHA}~1..${GITHUB_SHA}" ;; esac + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar xz gitleaks + ./gitleaks git --redact --no-banner --log-opts="$RANGE" . diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results new file mode 100644 index 0000000..6160f12 --- /dev/null +++ b/.phpunit.cache/test-results @@ -0,0 +1 @@ +{"version":2,"defects":{"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":7,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":7},"times":{"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_covers_every_enum_case":0.001,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_unknown_value":0,"Tests\\Unit\\BillingPricingTest::test_cycle_to_months_throws_on_null":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, no setup fee\"":0.004,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=1, with setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, no setup fee\"":0,"Tests\\Unit\\BillingPricingTest::test_domain_price_formula with data set \"years=3, with setup fee\"":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_replaces_known_placeholders_in_subject_and_html":0.006,"Tests\\Unit\\EmailTemplateRendererTest::test_it_falls_back_to_example_value_when_data_missing":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_html_escapes_string_type_by_default":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_rejects_invalid_url_type":0,"Tests\\Unit\\EmailTemplateRendererTest::test_it_throws_for_undeclared_variable":0.001,"Tests\\Unit\\EmailTemplateRendererTest::test_it_never_executes_placeholder_content_as_code":0,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_plain_password_is_stored_encrypted_and_reads_back_transparently":0.003,"Tests\\Unit\\Models\\ReinstallLogPlainPasswordTest::test_backfill_migration_is_idempotent":0.001,"Tests\\Unit\\ServiceFillableTest::test_service_ignores_fields_not_declared_in_fillable":0.031,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_linux_never_logs_the_guest_password":0.002,"Tests\\Unit\\Services\\VCenter\\PowerShellServiceSecretLoggingTest::test_change_ip_vm_windows_never_logs_the_guest_password":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.002,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\VCenterHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_store_ignores_fields_not_declared_in_validation_rules":0.025,"Tests\\Feature\\Api\\Admin\\Customer\\CustomerMassAssignmentTest::test_update_ignores_fields_not_declared_in_validation_rules":0.023,"Tests\\Feature\\Api\\Admin\\Customer\\ResetPasswordTest::test_reset_password_sends_email_and_does_not_leak_password_in_response":0.014,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_edit_a_full_administrator":0.012,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_delete_a_full_administrator":0.01,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_cannot_change_own_role":0.008,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_non_full_administrator_cannot_assign_administrator_role":0.012,"Tests\\Feature\\Api\\Admin\\Employee\\EmployeeRoleGuardTest::test_full_administrator_can_assign_administrator_role":0.015,"Tests\\Feature\\Api\\Admin\\InvoiceTestRouteRemovedTest::test_unauthenticated_invoice_test_routes_no_longer_exist":0.003,"Tests\\Feature\\Api\\Admin\\LoginThrottleTest::test_admin_login_is_throttled_after_5_attempts_per_minute":0.008,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"settings index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"banks index\"":0.013,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"domain-pricing index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-templates index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"tickets index\"":0.018,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"activity-logs index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"email-logs index\"":0.014,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"post-categories index\"":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"posts index\"":0.018,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"media index\"":0.099,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"dashboard stats\"":0.021,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_route_requires_expected_permission with data set \"queue jobs recent\"":0.02,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_force_delete_post_requires_post_destroy_permission":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_dashboard_requires_dashboard_view_permission":0.017,"Tests\\Feature\\Api\\Admin\\PermissionGatingTest::test_full_administrator_bypasses_all_permission_gates":0.043,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_older_than_skew_window_is_rejected":0.002,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_verify_email_code_is_invalidated_after_too_many_wrong_attempts":0.014,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions":0.445,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_cccd_images_are_private_and_served_only_via_signed_url":0.015,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_access_token_is_revoked_when_password_changes":0.02,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip":0.072,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_logout_revokes_refresh_token_so_it_cannot_be_used_again":0.011,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_reused_rotated_refresh_token_revokes_all_tokens_of_customer":0.011,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_legacy_cccd_path_is_not_exposed":0.009,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_rejects_short_password":0.004,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_register_accepts_password_with_at_least_ten_characters":0.009,"Tests\\Feature\\Api\\Client\\SecurityHardeningTest::test_cccd_upload_rejects_svg":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_admin_can_list_domain_orders":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_non_admin_cannot_access_domain_orders":0.002,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_confirm_registration_creates_dns_domain_and_attaches_customer":0.018,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_confirm_already_confirmed_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_create_duplicate_dns_domain":0.013,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_update_kyc_url_sets_pending_kyc_status":0.014,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_activate_transitions_from_pending_kyc_to_active":0.019,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_activate_pending_order_directly":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_restores_customer_balance":0.026,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_reject_and_refund_is_idempotent":0.031,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_cannot_reject_non_pending_order":0.012,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_refund_uses_invoice_amount_not_current_pricing":0.022,"Tests\\Feature\\Api\\DomainOrder\\AdminDomainOrderTest::test_transaction_rollback_on_error":0.01,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_authenticated_customer_can_place_domain_order":0.017,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_insufficient_balance_returns_422":0.006,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_duplicate_pending_domain_is_rejected":0.017,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unauthenticated_cannot_place_order":0.002,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_invalid_product_type_rejected":0.004,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_domain_normalized_to_lowercase":0.015,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_unknown_tld_returns_422":0.003,"Tests\\Feature\\Api\\DomainOrder\\ClientDomainOrderTest::test_years_defaults_to_1_when_not_provided":0.015,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_hides_unknown_exception_message":0.009,"Tests\\Feature\\Api\\Service\\OrderErrorMessageTest::test_pay_order_keeps_business_message_for_invalid_argument":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price":0.013,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price":0.017,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_uses_current_catalog_price_not_the_original_order_price":0.011,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_fails_when_product_pricing_is_no_longer_active":0.008,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_reuses_original_order_instead_of_creating_a_new_one":0.012,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period":0.015,"Tests\\Feature\\Api\\Service\\RenewServiceTest::test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice":0.02,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_cannot_fetch_another_customers_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\ServiceOwnershipTest::test_client_can_fetch_own_service_by_uuid":0.012,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_addon_belonging_to_another_product_type_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_inactive_addon_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_qty_above_max_is_rejected":0.007,"Tests\\Feature\\Api\\Service\\StoreOrderAddonTest::test_valid_addon_within_bounds_is_accepted":0.018,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_outside_pool_returns_422":0.025,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_already_in_use_returns_422":0.023,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway":0.021,"Tests\\Feature\\Api\\Service\\VmChangeIpTest::test_power_on_for_suspended_service_returns_403":0.023,"Tests\\Feature\\Api\\Service\\VmOwnershipTest::test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id":0.007,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter":0.01,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_qty_over_max_is_rejected":0.006,"Tests\\Feature\\Api\\Service\\VmUpgradeAddonsTest::test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta":0.019,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_proof_path_belonging_to_another_customer":0.009,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_rejects_traversal_and_reused_proof_paths":0.013,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_confirm_without_proof_is_rejected":0.006,"Tests\\Feature\\Api\\Transaction\\ManualDepositProofTest::test_upload_then_confirm_binds_proof_to_owner":0.013,"Tests\\Feature\\Console\\InvoiceReminderTest::test_no_reminder_is_sent_before_the_first_offset":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_reminder_step_advances_once_per_offset_reached":0.009,"Tests\\Feature\\Console\\InvoiceReminderTest::test_an_overdue_invoice_reaches_the_later_offsets":0.01,"Tests\\Feature\\Console\\InvoiceReminderTest::test_a_paid_invoice_is_never_reminded":0.006,"Tests\\Feature\\Console\\InvoiceReminderTest::test_manual_reminder_sends_even_when_the_offset_was_already_used":0.007,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices":0.007,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_recently_overdue_invoice_is_still_reminded":0.008,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_manual_reminder_still_works_on_an_old_invoice":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_legacy_pending_order_does_not_block_new_orders":0.005,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_local_pending_order_still_blocks_new_orders":0.004,"Tests\\Feature\\Console\\PostWhmcsSyncSafetyTest::test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order":0.009,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_creates_renewal_invoice_on_the_original_order":0.015,"Tests\\Feature\\Console\\ServiceExpiryTest::test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice":0.021,"Tests\\Feature\\Console\\ServiceExpiryTest::test_cancelled_service_does_not_get_a_renewal_invoice":0.012,"Tests\\Feature\\Console\\ServiceExpiryTest::test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue":0.021,"Tests\\Feature\\Console\\ServiceExpiryTest::test_overdue_renewal_invoice_does_not_reset_an_active_order":0.022,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_invoice_uses_the_current_price_list":0.018,"Tests\\Feature\\Console\\ServiceExpiryTest::test_renewal_falls_back_to_the_original_price_when_no_current_price_exists":0.017,"Tests\\Feature\\EmailTemplateServiceTest::test_send_dispatches_job_to_queue":0.001,"Tests\\Feature\\EmailTemplateServiceTest::test_job_renders_and_sends_mail_with_template_sender_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_preview_throws_for_undeclared_variable":0.002,"Tests\\Feature\\EmailTemplateServiceTest::test_load_template_is_cached_by_scenario":0.003,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once":0.012,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_credits_the_received_amount_not_the_declared_one":0.01,"Tests\\Feature\\Payment\\AdminTransactionApproveTest::test_approving_without_received_amount_credits_the_declared_amount":0.011,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_an_already_paid_invoice_does_not_double_deduct_balance":0.02,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid":0.007,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_admin_cannot_mark_a_cancelled_invoice_as_paid":0.007,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid":0.014,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_paying_the_remainder_settles_the_invoice":0.025,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_overpaying_only_charges_the_outstanding_amount":0.018,"Tests\\Feature\\Payment\\InvoicePaymentServiceTest::test_refund_returns_balance_and_records_a_negative_transaction":0.024,"Tests\\Feature\\Api\\Client\\ClientAuthSecurityTest::test_concurrent_refresh_within_grace_window_does_not_revoke_sessions":0.014,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_and_no_ca_bundle_uses_default_verification":0.001,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_true_with_ca_bundle_pins_the_bundle_path":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_verify_false_disables_verification":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_plesk_reads_its_own_config":0,"Tests\\Unit\\Support\\TlsHttpClientFactoryTest::test_curl_options_follow_vcenter_config":0,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_rotating_keys_re_encrypts_rsa_and_app_key_columns":0.078,"Tests\\Feature\\Console\\ReencryptSecretsTest::test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command":0.082,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_rejected_while_tls_verification_is_on":0.006,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_missing_known_hosts_is_allowed_when_tls_verification_is_off":0,"Tests\\Unit\\Support\\SshHostKeyVerifierTest::test_host_key_must_match_known_hosts_entry":0.001}} \ No newline at end of file diff --git a/app/Console/Commands/HostingMail.php b/app/Console/Commands/HostingMail.php index adc5141..c93e735 100644 --- a/app/Console/Commands/HostingMail.php +++ b/app/Console/Commands/HostingMail.php @@ -40,7 +40,7 @@ class HostingMail extends Command if ($Hosting) { - $ServiceExpiry = Carbon::now()->diffInDays(Carbon::parse($Hosting->next_due_date),false); + $ServiceExpiry = (int) Carbon::now()->diffInDays(Carbon::parse($Hosting->next_due_date), false); if ($ServiceExpiry <= 5 && $ServiceExpiry >= 0) { $jobs[] = new ExpiryMail($item->id,5,3); diff --git a/app/Console/Commands/ReencryptSecrets.php b/app/Console/Commands/ReencryptSecrets.php new file mode 100644 index 0000000..81d1956 --- /dev/null +++ b/app/Console/Commands/ReencryptSecrets.php @@ -0,0 +1,126 @@ +reencryptColumn($table, $column, fn (string $value) => SignRSA::EncryptFunc($this->decryptRsa($value))); + } + + foreach (self::APP_KEY_COLUMNS as [$table, $column]) { + $failed += $this->reencryptColumn($table, $column, fn (string $value) => Crypt::encryptString(Crypt::decryptString($value))); + } + + $failed += $this->reencryptSmtpSetting(); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + private function reencryptColumn(string $table, string $column, callable $reencrypt): int + { + if (!Schema::hasColumn($table, $column)) { + $this->warn("{$table}.{$column}: column not found, skipped"); + + return 0; + } + + $updated = 0; + $failed = 0; + + DB::table($table) + ->select(['id', $column]) + ->whereNotNull($column) + ->where($column, '!=', '') + ->chunkById(200, function ($rows) use ($table, $column, $reencrypt, &$updated, &$failed) { + foreach ($rows as $row) { + try { + $value = $reencrypt((string) $row->{$column}); + } catch (Throwable) { + $failed++; + $this->warn("{$table}#{$row->id}.{$column}: cannot decrypt with current or previous key"); + continue; + } + + if (!$this->option('dry-run')) { + DB::table($table)->where('id', $row->id)->update([$column => $value]); + } + $updated++; + } + }); + + $this->info("{$table}.{$column}: {$updated} re-encrypted, {$failed} failed"); + + return $failed; + } + + private function reencryptSmtpSetting(): int + { + $setting = DB::table('setting')->where('key', 'smtp')->first(); + $smtp = $setting ? json_decode((string) $setting->value, true) : null; + + if (empty($smtp['password'])) { + return 0; + } + + try { + $smtp['password'] = SignRSA::EncryptFunc($this->decryptRsa($smtp['password'])); + } catch (Throwable) { + $this->warn('setting[smtp].password: cannot decrypt with current or previous key'); + + return 1; + } + + if (!$this->option('dry-run')) { + DB::table('setting')->where('key', 'smtp')->update(['value' => json_encode($smtp)]); + } + $this->info('setting[smtp].password: re-encrypted'); + + return 0; + } + + private function decryptRsa(string $value): string + { + $plain = SignRSA::DecryptFunc($value); + if ($plain === '') { + throw new RuntimeException('RSA decrypt failed'); + } + + return $plain; + } +} diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php index 411589d..37dc71d 100644 --- a/app/Console/Commands/ServiceExpiry.php +++ b/app/Console/Commands/ServiceExpiry.php @@ -11,6 +11,7 @@ use App\Repositories\Api\Service\ServiceRepository; use App\Models\Invoice; use App\Models\InvoiceItem; use App\Models\Service; +use App\Support\RenewalPricing; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -230,8 +231,10 @@ class ServiceExpiry extends Command return; } - $price = $this->renewalPrice($orderItem); + $renewalPrice = $this->renewalPrice($orderItem); $months = $orderItem->months ?? 1; + $addonTotal = RenewalPricing::addonTotal($service, 1); + $price = $renewalPrice + $addonTotal; if ($price <= 0) { Log::warning('service:expiry | Cannot create renewal invoice: zero price', ['service_id' => $service->id]); @@ -270,12 +273,16 @@ class ServiceExpiry extends Command 'product_id' => $service->product_id, 'type' => 'renewal', 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), - 'months' => $orderItem->months ?? 1, + 'months' => $months, 'quantity' => 1, - 'unit_price' => $price, - 'total_price' => $price, + 'unit_price' => $renewalPrice, + 'total_price' => $renewalPrice, ]); + foreach (RenewalPricing::addonInvoiceItems($service, 1, $months) as $addonItem) { + InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]); + } + // Update service to point to the new renewal invoice $service->update(['invoice_id' => $invoice->id]); diff --git a/app/Console/Commands/ServiceHosting.php b/app/Console/Commands/ServiceHosting.php index a85dcfa..2b8a321 100644 --- a/app/Console/Commands/ServiceHosting.php +++ b/app/Console/Commands/ServiceHosting.php @@ -39,7 +39,7 @@ class ServiceHosting extends Command $Hosting = ModuleHosting::with('Server')->where('customer_id',$item->id)->first(); if ($Hosting) { - $ServiceExpiry = Carbon::now()->diffInDays(Carbon::parse($Hosting->next_due_date),false); + $ServiceExpiry = (int) Carbon::now()->diffInDays(Carbon::parse($Hosting->next_due_date), false); if ($ServiceExpiry <= 0) { $jobs[] = new HostingJobs($item->id,'Suspend'); diff --git a/app/Console/Commands/ServiceVPS.php b/app/Console/Commands/ServiceVPS.php index 41ad433..febce87 100644 --- a/app/Console/Commands/ServiceVPS.php +++ b/app/Console/Commands/ServiceVPS.php @@ -42,7 +42,7 @@ class ServiceVPS extends Command { foreach ($allService as $service) { - $ServiceExpiry = Carbon::now()->diffInDays(Carbon::parse($service->next_due_date),false); + $ServiceExpiry = (int) Carbon::now()->diffInDays(Carbon::parse($service->next_due_date), false); if ($ServiceExpiry <= 0) { diff --git a/app/Console/Commands/VPSMail.php b/app/Console/Commands/VPSMail.php index e26d5d3..773aa42 100644 --- a/app/Console/Commands/VPSMail.php +++ b/app/Console/Commands/VPSMail.php @@ -39,7 +39,7 @@ class VPSMail extends Command $vps = ModuleVmware::where('customer_id',$item->id)->first(); if ($vps) { - $ServiceExpiry = Carbon::now()->diffInDays(Carbon::parse($vps->next_due_date),false); + $ServiceExpiry = (int) Carbon::now()->diffInDays(Carbon::parse($vps->next_due_date), false); if ($ServiceExpiry <= 5 && $ServiceExpiry > 0) { $jobs[] = new ExpiryMail($vps->id,5,4); diff --git a/app/Console/Commands/Whmcs/SyncAllCommand.php b/app/Console/Commands/Whmcs/SyncAllCommand.php new file mode 100644 index 0000000..f786b8a --- /dev/null +++ b/app/Console/Commands/Whmcs/SyncAllCommand.php @@ -0,0 +1,61 @@ + 'whmcs:sync-clients', 'forceSync' => true], + ['command' => 'whmcs:sync-products', 'forceSync' => false], + ['command' => 'whmcs:sync-orders', 'forceSync' => true], + ['command' => 'whmcs:sync-order-items', 'forceSync' => false], + ['command' => 'whmcs:sync-invoices', 'forceSync' => true], + ['command' => 'whmcs:sync-services', 'forceSync' => false], + ['command' => 'whmcs:sync-domains', 'forceSync' => false], + ]; + + public function handle(): int + { + if ($this->option('with-domain-sync')) { + $exitCode = $this->runStep('domain:sync', []); + if ($exitCode !== self::SUCCESS) { + return $exitCode; + } + } else { + $this->warn('Skipping domain:sync — whmcs:sync-domains expects it to already be up to date. Pass --with-domain-sync to run it here.'); + } + + foreach (self::STEPS as $step) { + $arguments = $step['forceSync'] ? ['--sync' => true] : []; + $exitCode = $this->runStep($step['command'], $arguments); + + if ($exitCode !== self::SUCCESS) { + return $exitCode; + } + } + + $this->info('whmcs:sync-all completed successfully.'); + + return self::SUCCESS; + } + + private function runStep(string $command, array $arguments): int + { + $this->newLine(); + $this->info("==> {$command}"); + + $exitCode = $this->call($command, $arguments); + + if ($exitCode !== self::SUCCESS) { + $this->error("{$command} failed with exit code {$exitCode}; stopping."); + } + + return $exitCode; + } +} diff --git a/app/Enums/Product/ProductBillingCycle.php b/app/Enums/Product/ProductBillingCycle.php index 5bc1120..ac150fe 100644 --- a/app/Enums/Product/ProductBillingCycle.php +++ b/app/Enums/Product/ProductBillingCycle.php @@ -7,7 +7,7 @@ enum ProductBillingCycle: string case ONE_TIME = 'onetime'; case MONTHLY = 'monthly'; case QUARTERLY = 'quarterly'; - case SEMI_ANNUALLY = 'semiannually'; + case SEMI_ANNUALLY = 'semi_annually'; case ANNUALLY = 'annually'; case BIENNIALLY = 'biennially'; case TRIENNIALLY = 'triennially'; diff --git a/app/Helpers/OrderHelper.php b/app/Helpers/OrderHelper.php index 62d2669..c618841 100644 --- a/app/Helpers/OrderHelper.php +++ b/app/Helpers/OrderHelper.php @@ -2,6 +2,7 @@ namespace App\Helpers; +use App\Enums\Product\ProductBillingCycle; use App\Models\Invoice; use App\Models\Order; use Illuminate\Support\Str; @@ -31,17 +32,14 @@ class OrderHelper return $number; } - /** Number of months a billing_cycle value represents (used for pricing and expires_at math). */ + /** + * @throws \InvalidArgumentException + */ public static function cycleToMonths(?string $cycle): int { - return match ($cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - default => 1, - }; + $enum = ProductBillingCycle::tryFrom($cycle ?? '') + ?? throw new \InvalidArgumentException("Unknown billing cycle: " . ($cycle ?? 'null')); + + return $enum === ProductBillingCycle::ONE_TIME ? 1 : $enum->months(); } } diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php index 6d07634..5b96bab 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php @@ -9,11 +9,15 @@ use App\Services\ActivityLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Tymon\JWTAuth\Facades\JWTAuth; use Tymon\JWTAuth\Exceptions\JWTException; class AuthController extends Controller { + private const LOGIN_MAX_ATTEMPTS = 5; + private const LOGIN_DECAY_SECONDS = 60; + public function __construct(private readonly ActivityLogger $activityLogger) {} public function login(Request $request): JsonResponse @@ -23,9 +27,20 @@ class AuthController extends Controller 'password' => 'required|string', ]); + $throttleKey = 'admin-login:' . strtolower($request->string('username')->toString()) . '|' . $request->ip(); + + if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { + return response()->json([ + 'error' => true, + 'message' => 'Bạn đã đăng nhập sai quá nhiều lần. Vui lòng thử lại sau ít phút.', + ], 429); + } + $token = Auth::guard('admin')->attempt($request->only('username', 'password')); if (!$token) { + RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + $this->activityLogger->log( event: 'login_failed', logName: 'auth', @@ -37,6 +52,8 @@ class AuthController extends Controller ], 401); } + RateLimiter::clear($throttleKey); + $user = Auth::guard('admin')->user(); $this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email); diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index eac2a9d..581883e 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -362,6 +362,8 @@ class CustomerController extends Controller 'started_at' => 'nullable|date', 'expires_at' => 'nullable|date', 'note' => 'nullable|string', + 'requirements' => 'nullable|string', + 'feedback' => 'nullable|string', 'price' => 'nullable|numeric|min:0', 'os_id' => 'nullable|integer|exists:os,id', 'vm_cpu' => 'nullable|integer|min:1', diff --git a/app/Http/Controllers/Api/Admin/DnsManager/RegistrarController.php b/app/Http/Controllers/Api/Admin/DnsManager/RegistrarController.php new file mode 100644 index 0000000..f1bb9c0 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DnsManager/RegistrarController.php @@ -0,0 +1,18 @@ +json([ + 'error' => false, + 'data' => Registrar::where('status', 'active')->orderBy('name')->get(['id', 'name', 'short_name', 'website']), + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php b/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php index 8711f76..2bd8642 100644 --- a/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php +++ b/app/Http/Controllers/Api/Admin/Employee/EmployeeController.php @@ -92,6 +92,11 @@ class EmployeeController extends Controller $request->validated(); + $guardError = $this->guardEmployeeMutation($employee, $request->role); + if ($guardError) { + return $guardError; + } + try { DB::beginTransaction(); @@ -138,6 +143,14 @@ class EmployeeController extends Controller ], 404); } + $currentUser = auth('admin')->user(); + if ($employee->hasRole('Full Administrator') && !$currentUser->hasRole('Full Administrator')) { + return response()->json([ + 'error' => true, + 'message' => 'Bạn không có quyền xoá tài khoản Full Administrator.', + ], 403); + } + try { DB::beginTransaction(); $this->employeeRepository->delete($id); @@ -156,6 +169,34 @@ class EmployeeController extends Controller } } + private function guardEmployeeMutation($employee, string $requestedRole): ?JsonResponse + { + $currentUser = auth('admin')->user(); + + if ($employee->hasRole('Full Administrator') && !$currentUser->hasRole('Full Administrator')) { + return response()->json([ + 'error' => true, + 'message' => 'Bạn không có quyền sửa tài khoản Full Administrator.', + ], 403); + } + + if ($employee->id === $currentUser->id && !$currentUser->hasRole($requestedRole)) { + return response()->json([ + 'error' => true, + 'message' => 'Bạn không thể tự đổi vai trò của chính mình.', + ], 403); + } + + if (in_array($requestedRole, ['Administrator', 'Full Administrator'], true) && !$currentUser->hasRole('Full Administrator')) { + return response()->json([ + 'error' => true, + 'message' => 'Chỉ Full Administrator mới có quyền gán vai trò này.', + ], 403); + } + + return null; + } + public function roles(): JsonResponse { $roles = Role::where('guard_name', 'admin')->get(['id', 'name']); diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 9dfc68b..2d3f804 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -194,13 +194,13 @@ class ProductController extends Controller { $currency = $data['currency'] ?? 'VND'; $cycles = [ - 'monthly' => 'price_monthly', - 'quarterly' => 'price_quarterly', - 'semiannually' => 'price_semiannually', - 'annually' => 'price_annually', - 'biennially' => 'price_biennially', - 'triennially' => 'price_triennially', - 'onetime' => 'price_onetime', + 'monthly' => 'price_monthly', + 'quarterly' => 'price_quarterly', + 'semi_annually' => 'price_semiannually', + 'annually' => 'price_annually', + 'biennially' => 'price_biennially', + 'triennially' => 'price_triennially', + 'onetime' => 'price_onetime', ]; foreach ($cycles as $cycle => $field) { diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index 758df87..4ccaf2c 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -36,7 +36,7 @@ class ServiceController extends Controller public function show(int $id): JsonResponse { - $service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware.os.osGroup', 'addons']) + $service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware.os.osGroup', 'addons', ...Service::HOSTING_ACCOUNT_RELATIONS]) ->findOrFail($id); ModuleVmware::resolveVirtualMachineIds([$service->vmware]); diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 7e9a34e..52c188f 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -186,7 +186,7 @@ class SettingController extends Controller public function uploadMedia(Request $request): JsonResponse { $request->validate([ - 'file' => 'required|file|mimes:jpg,jpeg,png,webp,svg|max:5120', + 'file' => 'required|file|mimes:jpg,jpeg,png,webp|max:5120', ]); $path = $request->file('file')->store('system-config', 'public'); diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php index ad9a8bf..306c34a 100644 --- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php +++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php @@ -64,7 +64,7 @@ class AdminTransactionController extends Controller 'type' => $t->type, 'status' => $t->status, 'status_label' => TransactionStatus::from((int) $t->status)->label(), - 'proof_image' => $t->proof_image ? Storage::disk('public')->url($t->proof_image) : null, + 'proof_image' => $t->proof_image_urls[0] ?? null, 'created_at' => $t->created_at?->toISOString(), 'customer' => $t->customer ? [ 'uuid' => $t->customer->uuid, @@ -287,4 +287,21 @@ class AdminTransactionController extends Controller 'message' => 'Đã từ chối giao dịch', ]); } + + public function proofImage(Transaction $transaction, int $index = 0) + { + abort_unless($transaction->proof_image, 404); + + $paths = json_decode((string) $transaction->proof_image, true); + $paths = is_array($paths) ? array_values($paths) : [$transaction->proof_image]; + $path = $paths[$index] ?? null; + + abort_unless(is_string($path) && $path !== '', 404); + + $disk = Storage::disk('local')->exists($path) ? 'local' : 'public'; + + abort_unless(Storage::disk($disk)->exists($path), 404); + + return Storage::disk($disk)->response($path, null, ['Cache-Control' => 'private, no-store']); + } } diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 84e6dbc..271349e 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -14,6 +14,7 @@ use App\Services\ActivityLogger; use App\Services\Api\Client\ClientAuthService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\Rules\Password; /** * @OA\Tag( @@ -411,7 +412,7 @@ class ClientController extends Controller $data = $request->validate([ 'email' => ['required', 'email', 'max:255'], 'token' => ['required', 'string', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], + 'password' => ['required', Password::min(10), 'confirmed'], ]); $customer = $this->authService->resetPassword($data); @@ -439,7 +440,17 @@ class ClientController extends Controller abort_unless($path && str_starts_with($path, 'cccd/') && Storage::disk('local')->exists($path), 404); - return Storage::disk('local')->response($path, null, ['Cache-Control' => 'private, no-store']); + $mime = match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { + 'png' => 'image/png', + 'webp' => 'image/webp', + default => 'image/jpeg', + }; + + return Storage::disk('local')->response($path, "cccd-{$side}", [ + 'Content-Type' => $mime, + 'Content-Disposition' => "inline; filename=\"cccd-{$side}\"", + 'Cache-Control' => 'private, no-store', + ]); } public function update(ClientUpdateRequest $request) diff --git a/app/Http/Controllers/Api/Client/Service/DomainController.php b/app/Http/Controllers/Api/Client/Service/DomainController.php index 5d69eb4..db4421e 100644 --- a/app/Http/Controllers/Api/Client/Service/DomainController.php +++ b/app/Http/Controllers/Api/Client/Service/DomainController.php @@ -112,7 +112,9 @@ class DomainController extends Controller } catch (\Throwable $e) { DB::rollBack(); - return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + \Log::error('DomainController::storeRecord error: ' . $e->getMessage()); + + return response()->json(['error' => true, 'message' => 'Có lỗi xảy ra, vui lòng thử lại'], 500); } $customer = $request->user(); diff --git a/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php index 835e055..335affc 100644 --- a/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php +++ b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php @@ -9,7 +9,7 @@ use Illuminate\Http\Request; trait ResolvesVm { - protected function resolveVmById(int|string $productId, Request $request): ModuleVmware|JsonResponse + protected function resolveVmById(int|string $productId, Request $request, bool $requireActiveService = true): ModuleVmware|JsonResponse { $customer = $request->user(); $vm = null; @@ -42,6 +42,13 @@ trait ResolvesVm return response()->json(['error' => true, 'message' => 'Bạn không có quyền thao tác VM này'], 403); } + if ($requireActiveService) { + $service = $vm->service ?? Service::where('vm_id', $vm->vmware_id)->first(); + if ($service && $service->status !== 'active') { + return response()->json(['error' => true, 'message' => 'Dịch vụ không ở trạng thái hoạt động, không thể thao tác.'], 403); + } + } + return $vm; } diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php index 9ca978c..ad7b57e 100644 --- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -105,8 +105,16 @@ class OrderControllerRefactor extends Controller ], 201, ); - } catch (\Throwable $e) { + } catch (\Illuminate\Validation\ValidationException $e) { + return response()->json( + ['success' => false, 'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage()], + 422, + ); + } catch (\InvalidArgumentException $e) { return response()->json(['success' => false, 'message' => $e->getMessage()], 400); + } catch (\Throwable $e) { + \Log::error('OrderControllerRefactor::storeOrder error: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => 'Có lỗi xảy ra, vui lòng thử lại'], 500); } } @@ -120,10 +128,11 @@ class OrderControllerRefactor extends Controller try { $this->orderService->pay($order, $request->payment_method); - } catch (\Throwable $e) { - // Số dư không đủ / đơn đã thanh toán là lỗi nghiệp vụ, không phải lỗi server — - // trả 422 cho khớp với payClientInvoiceByNumber. + } catch (\InvalidArgumentException $e) { return response()->json(['success' => false, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + \Log::error('OrderControllerRefactor::payOrder error: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => 'Có lỗi xảy ra, vui lòng thử lại'], 500); } $this->activityLogger->log( @@ -250,8 +259,11 @@ class OrderControllerRefactor extends Controller : 'Thanh toán hóa đơn thành công.', 'data' => new InvoiceDetailResource($paidInvoice), ]); - } catch (\Throwable $e) { + } catch (\InvalidArgumentException $e) { return response()->json(['success' => false, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + \Log::error('OrderControllerRefactor::payClientInvoiceByNumber error: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => 'Có lỗi xảy ra, vui lòng thử lại'], 500); } } diff --git a/app/Http/Controllers/Api/Service/ProductCategoryController.php b/app/Http/Controllers/Api/Service/ProductCategoryController.php index 8ff7e59..1d6741f 100644 --- a/app/Http/Controllers/Api/Service/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Service/ProductCategoryController.php @@ -286,33 +286,33 @@ class ProductCategoryController extends Controller private function formatProduct($product): array { $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semiannually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'biennially' => '2 năm', - 'triennially' => '3 năm', - 'onetime' => 'Một lần', + 'monthly' => 'Hàng tháng', + 'quarterly' => 'Hàng quý', + 'semi_annually' => 'Nửa năm', + 'annually' => 'Hàng năm', + 'biennially' => '2 năm', + 'triennially' => '3 năm', + 'onetime' => 'Một lần', ]; $periodMonths = [ - 'monthly' => 1, - 'quarterly' => 3, - 'semiannually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - 'onetime' => 0, + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + 'onetime' => 0, ]; $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semiannually' => 'semi_year', - 'annually' => 'year', - 'biennially' => 'biennially', - 'triennially' => 'triennially', - 'onetime' => 'none', + 'monthly' => 'month', + 'quarterly' => 'quarter', + 'semi_annually' => 'semi_year', + 'annually' => 'year', + 'biennially' => 'biennially', + 'triennially' => 'triennially', + 'onetime' => 'none', ]; return [ diff --git a/app/Http/Controllers/Api/Service/ServiceController.php b/app/Http/Controllers/Api/Service/ServiceController.php index 066905c..ec32b1f 100644 --- a/app/Http/Controllers/Api/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Service/ServiceController.php @@ -191,7 +191,9 @@ class ServiceController extends Controller public function getDetailServiceCustomer(Request $request, $uuid) { - $service = Service::where('uuid', $uuid)->first(); + $service = Service::where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->first(); if (!$service) { return response()->json([ diff --git a/app/Http/Controllers/Api/Service/SnapshotController.php b/app/Http/Controllers/Api/Service/SnapshotController.php index 489a71f..339593e 100644 --- a/app/Http/Controllers/Api/Service/SnapshotController.php +++ b/app/Http/Controllers/Api/Service/SnapshotController.php @@ -76,7 +76,7 @@ class SnapshotController extends Controller return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); } - $vm = $this->resolveVmById($productParam, $request); + $vm = $this->resolveVmById($productParam, $request, requireActiveService: false); if ($vm instanceof JsonResponse) { return $vm; } diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php index 6b1989a..0974090 100644 --- a/app/Http/Controllers/Api/Service/VmController.php +++ b/app/Http/Controllers/Api/Service/VmController.php @@ -10,10 +10,12 @@ use App\Http\Controllers\Controller; use App\Http\Services\vCenter; use App\Jobs\VirtualMachine\ChangeIpVMJob; use App\Jobs\VirtualMachine\ChangeVmPasswordJob; +use App\Models\IpPool; use App\Models\ModuleVmware; use App\Models\Os; use App\Models\Service; use App\Services\ActivityLogger; +use App\Services\VirtualMachine\VmIpReservationService; use App\Services\VmProvisionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -21,9 +23,7 @@ use Illuminate\Support\Facades\Cache; class VmController extends Controller { - use ResolvesVm { - ResolvesVm::resolveVmById as resolveVmForClient; - } + use ResolvesVm; public function __construct(protected ActivityLogger $activityLogger) {} // ------------------------------------------------------------------------- @@ -114,43 +114,67 @@ class VmController extends Controller // Change IP // ------------------------------------------------------------------------- + /** + * GET /v1/system/vm-ip-options + * + * Query: server_name string Service.server_name + * Lists free IP pool entries in the same network as the VM's current IP. + */ + public function ipOptions(Request $request): JsonResponse + { + $validated = $request->validate([ + 'server_name' => ['required', 'string', 'max:255'], + ]); + + $service = $this->findOwnedServiceByServerName($validated['server_name'], $request); + if ($service instanceof JsonResponse) { + return $service; + } + + $vm = $service->orderItem?->order?->vmWare?->first(); + if (!$vm) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404); + } + + $networkId = $vm->ipAddress?->module_network_id; + if (!$networkId) { + return response()->json(['success' => false, 'message' => 'Không xác định được mạng của VM.'], 422); + } + + $ips = app(VmIpReservationService::class)->freeIpsInNetwork($networkId); + + return response()->json([ + 'success' => true, + 'data' => $ips->map(fn (IpPool $ip) => [ + 'ip_address' => $ip->ip_address, + 'gateway' => $ip->gateway, + ])->values(), + ]); + } + /** * POST /v1/system/vm-change-ip * * Body: * server_name string Service.server_name (e.g. "xasdf.com.vnx") - * ip_address string New IP to assign - * gateway string (optional) Gateway — falls back to existing IP record + * ip_address string New IP to assign — must be a free IP from the VM's network pool */ /** * Dispatches ChangeIpVMJob — returns immediately, runs async. */ - public function changeIp(Request $request): JsonResponse + public function changeIp(Request $request, VmIpReservationService $ipReservation): JsonResponse { $validated = $request->validate([ 'server_name' => ['required', 'string', 'max:255'], 'ip_address' => ['required', 'ip'], - 'gateway' => ['nullable', 'ip'], 'dns' => ['nullable', 'ip'], 'guest_user' => ['required', 'string', 'max:255'], 'guest_password' => ['required', 'string', 'max:255'], ]); - $customer = $request->user(); - - // Look up Service by server_name + owner - // VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource) - $service = Service::with([ - 'orderItem.order.vmWare.vCenter', - 'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter', - 'orderItem.os.osGroup', - ]) - ->where('server_name', $validated['server_name']) - ->where('customer_id', $customer->id) - ->first(); - - if (!$service) { - return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404); + $service = $this->findOwnedServiceByServerName($validated['server_name'], $request); + if ($service instanceof JsonResponse) { + return $service; } $vm = $service->orderItem?->order?->vmWare?->first(); @@ -159,29 +183,47 @@ class VmController extends Controller return response()->json(['success' => false, 'message' => 'Không tìm thấy VM cho dịch vụ này.'], 404); } + $networkId = $vm->ipAddress?->module_network_id; + if (!$networkId) { + return response()->json(['success' => false, 'message' => 'Không xác định được mạng của VM.'], 422); + } + + $reservation = $ipReservation->reserveInNetwork($networkId, $validated['ip_address'], $service->id); + if (!$reservation) { + return response()->json(['success' => false, 'message' => 'IP không thuộc pool khả dụng hoặc đã được sử dụng.'], 422); + } + + $oldIpPoolId = IpPool::where('module_network_id', $networkId) + ->where('service_id', $service->id) + ->where('status', 'assigned') + ->where('id', '!=', $reservation['ip_pool_id']) + ->value('id'); + // Auto-detect OS type from the service's order item OS group $osGroup = strtolower($service->orderItem?->os?->osGroup?->name ?? ''); $osType = (str_contains($osGroup, 'linux') || str_contains($osGroup, 'ubuntu') || str_contains($osGroup, 'centos')) ? 'linux' : 'windows'; - $gateway = $validated['gateway'] ?? ''; - $dns = $validated['dns'] ?? '8.8.8.8'; + $dns = $validated['dns'] ?? '8.8.8.8'; // ponytail: don't release — lock expires after 5 min while the async job runs $lock = $this->acquireVmLock($vm, 300); if (!$lock) { + $ipReservation->releaseByPoolId($reservation['ip_pool_id']); return $this->vmLocked(); } ChangeIpVMJob::dispatch( $vm->id, $validated['ip_address'], - $gateway, + $reservation['gateway'], $osType, $dns, $validated['guest_user'], $validated['guest_password'], + $reservation['ip_pool_id'], + $oldIpPoolId, ); $this->activityLogger->log( @@ -189,7 +231,7 @@ class VmController extends Controller logName: 'VM', description: "Customer {$request->user()->email} yêu cầu đổi IP VM: {$vm->name} → {$validated['ip_address']}", subject: $vm, - newValues: ['ip_address' => $validated['ip_address'], 'gateway' => $gateway, 'os_type' => $osType], + newValues: ['ip_address' => $validated['ip_address'], 'gateway' => $reservation['gateway'], 'os_type' => $osType], ); return response()->json([ @@ -198,6 +240,35 @@ class VmController extends Controller ]); } + /** + * Look up a Service by server_name, verify ownership and active status. + * Returns JsonResponse on error, Service on success. + */ + private function findOwnedServiceByServerName(string $serverName, Request $request): Service|JsonResponse + { + $customer = $request->user(); + + // VM chain: Service → orderItem → order → vmWare (same as ServiceClientDetailResource) + $service = Service::with([ + 'orderItem.order.vmWare.vCenter', + 'orderItem.order.vmWare.ipAddress.host.datacenter.vCenter', + 'orderItem.os.osGroup', + ]) + ->where('server_name', $serverName) + ->where('customer_id', $customer->id) + ->first(); + + if (!$service) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404); + } + + if ($service->status !== 'active') { + return response()->json(['success' => false, 'message' => 'Dịch vụ không ở trạng thái hoạt động, không thể thao tác.'], 403); + } + + return $service; + } + // ------------------------------------------------------------------------- // Change Port (RDP) // ------------------------------------------------------------------------- @@ -319,7 +390,7 @@ class VmController extends Controller return response()->json(['error' => true, 'message' => 'Thiếu product id'], 422); } - $vm = $this->resolveVmForClient($productId, $request); + $vm = $this->resolveVmById($productId, $request); if ($vm instanceof JsonResponse) { return $vm; } @@ -431,7 +502,7 @@ class VmController extends Controller return response()->json(['success' => false, 'message' => 'Thiếu product id.'], 422); } - $vm = $this->resolveVmById($productId, $request); + $vm = $this->resolveVmById($productId, $request, requireActiveService: false); if ($vm instanceof JsonResponse) { return $vm; } @@ -462,60 +533,6 @@ class VmController extends Controller // Shared helpers // ------------------------------------------------------------------------- - /** - * Load a ModuleVmware by ID (int), vmware_id (string), or Service UUID. - * Verify the authenticated user owns it. - * Returns JsonResponse on error, ModuleVmware on success. - */ - private function resolveVmById(int|string $productId, Request $request): ModuleVmware|JsonResponse - { - $customer = $request->user(); - $vm = null; - - // Check if it's a UUID (Service UUID) - if (is_string($productId) && preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $productId)) { - $service = Service::with('vmware.ipAddress.host.datacenter.vCenter', 'vmware.vCenter') - ->where('uuid', $productId) - ->where('customer_id', $customer->id) - ->first(); - if ($service && $service->vmware) { - $vm = $service->vmware; - } - } elseif (is_numeric($productId)) { - // Numeric ID - $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) - ->where('id', (int) $productId) - ->where('customer_id', $customer->id) - ->first(); - } else { - // vmware_id string (e.g., "vm-3238") - $vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter']) - ->where('vmware_id', $productId) - ->where('customer_id', $customer->id) - ->first(); - } - - if (!$vm) { - return response()->json(['success' => false, 'message' => 'Không tìm thấy VM.'], 404); - } - - if ((int) $vm->customer_id !== (int) $customer->id) { - return response()->json(['success' => false, 'message' => 'Bạn không có quyền thao tác VM này.'], 403); - } - - return $vm; - } - - /** - * Load a ModuleVmware by ID, verify the authenticated user owns it. - * Returns JsonResponse on error, ModuleVmware on success. - * @deprecated Use resolveVmById instead for better ID format support - */ - private function resolveVm(int $id, Request $request): ModuleVmware|JsonResponse - { - return $this->resolveVmById($id, $request); - } - /** Try to acquire a per-VM operation lock. Returns null if already locked. */ private function acquireVmLock(ModuleVmware $vm, int $ttl = 60): ?\Illuminate\Contracts\Cache\Lock { diff --git a/app/Http/Controllers/Api/Service/VmRebuildController.php b/app/Http/Controllers/Api/Service/VmRebuildController.php index 2a10080..53cb9e2 100644 --- a/app/Http/Controllers/Api/Service/VmRebuildController.php +++ b/app/Http/Controllers/Api/Service/VmRebuildController.php @@ -2,15 +2,22 @@ namespace App\Http\Controllers\Api\Service; +use App\Enums\Invoice\InvoiceStatus; use App\Http\Controllers\Controller; use App\Http\Controllers\Api\Service\Concerns\ResolvesVm; -use App\Http\Services\vCenter; +use App\Helpers\OrderHelper; +use App\Models\Invoice; +use App\Models\InvoiceItem; +use App\Models\Service; use App\Services\ActivityLogger; +use App\Services\InvoicePaymentService; use App\Services\VirtualMachine\Rebuild\VmRebuildService; +use App\Support\AddonQuantityValidator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; use Throwable; class VmRebuildController extends Controller @@ -103,7 +110,7 @@ class VmRebuildController extends Controller return response()->json([ 'error' => true, - 'message' => 'Không thể gửi yêu cầu rebuild: ' . $e->getMessage(), + 'message' => 'Không thể gửi yêu cầu rebuild, vui lòng thử lại sau.', ], 400); } @@ -179,7 +186,9 @@ class VmRebuildController extends Controller ]); } - public function upgradeAddons(Request $request, vCenter $serviceVCenter): JsonResponse + private const INVOICE_TYPE_UPGRADE = 3; + + public function upgradeAddons(Request $request): JsonResponse { $customer = $request->user(); $payload = $request->validate([ @@ -188,7 +197,7 @@ class VmRebuildController extends Controller 'ram' => ['nullable', 'integer', 'min:0'], 'disk' => ['nullable', 'integer', 'min:0'], 'bandwidth' => ['nullable', 'integer', 'min:0'], - 'gpu' => ['nullable'], + 'gpu' => ['nullable', 'integer', 'min:0'], ]); $vm = $this->resolveVmById($payload['product'], $request); @@ -196,49 +205,160 @@ class VmRebuildController extends Controller return $vm; } - $vm->loadMissing(['vCenter', 'ipAddress.host.datacenter.vCenter']); + $vm->loadMissing(['order.orderItems.product']); - $vcenter = $vm->vCenter ?? $vm->ipAddress?->host?->datacenter?->vCenter; - if (!$vcenter || !$serviceVCenter->loginServer($vcenter)) { - return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vCenter'], 400); + $orderItem = $vm->order?->orderItems?->first(); + $product = $orderItem?->product; + if (!$orderItem || !$product) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy product của dịch vụ hiện tại'], 404); } - $applied = []; + $service = Service::where('order_item_id', $orderItem->id)->first(); + if (!$service) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ hiện tại'], 404); + } - if (isset($payload['cpu']) && $payload['cpu'] > 0) { - $res = $serviceVCenter->UpgradeCPUVirtualMachine($vm->vmware_id, (int) $payload['cpu']); - if (is_array($res) && !($res['error'] ?? true)) { - $applied[] = 'cpu'; - } + $requestedQtyByCode = array_filter([ + 'cpu' => (int) ($payload['cpu'] ?? 0), + 'ram' => (int) ($payload['ram'] ?? 0), + 'disk' => (int) ($payload['disk'] ?? 0), + 'bandwidth' => (int) ($payload['bandwidth'] ?? 0), + 'gpu' => (int) ($payload['gpu'] ?? 0), + ], fn ($qty) => $qty > 0); + + if (empty($requestedQtyByCode)) { + return response()->json(['error' => true, 'message' => 'Vui lòng chọn ít nhất một addon để nâng cấp'], 422); } - if (isset($payload['ram']) && $payload['ram'] > 0) { - $res = $serviceVCenter->UpgradeMemoryVirtualMachine($vm->vmware_id, (int) $payload['ram']); - if (is_array($res) && !($res['error'] ?? true)) { - $applied[] = 'ram'; + + $activeAddons = $product->addons()->where('status', true)->get()->keyBy('id'); + $addonsByCode = $activeAddons->keyBy(fn ($addon) => strtolower((string) $addon->code)); + + $raw = []; + foreach ($requestedQtyByCode as $code => $qty) { + $addon = $addonsByCode->get($code); + if (!$addon) { + return response()->json(['error' => true, 'message' => "Addon \"{$code}\" không áp dụng cho sản phẩm này"], 422); } + $raw[] = ['addon_id' => $addon->id, 'qty' => $qty]; } - if (isset($payload['disk']) && $payload['disk'] > 0) { - $res = $serviceVCenter->UpgradeDiskVirtualMachine($vm, (int) $payload['disk']); - if (is_array($res) && !($res['error'] ?? true)) { - $applied[] = 'disk'; - } + + $existingQtyByAddonId = collect(is_array($orderItem->addons_meta) ? $orderItem->addons_meta : []) + ->mapWithKeys(fn ($entry) => [(int) ($entry['addon_id'] ?? 0) => (int) ($entry['qty'] ?? 0)]) + ->all(); + + try { + $resolved = AddonQuantityValidator::resolve($raw, $activeAddons, $existingQtyByAddonId); + } catch (ValidationException $e) { + return response()->json([ + 'error' => true, + 'message' => collect($e->errors())->flatten()->first() ?? 'Yêu cầu nâng cấp không hợp lệ', + ], 422); + } + + $existingInvoice = Invoice::where('service_id', $service->id) + ->where('invoice_type', self::INVOICE_TYPE_UPGRADE) + ->whereIn('status', InvoicePaymentService::PAYABLE_STATUSES) + ->first(); + + if ($existingInvoice) { + return response()->json([ + 'error' => false, + 'message' => 'Dịch vụ đã có hóa đơn nâng cấp đang chờ thanh toán.', + 'data' => $this->invoiceResponse($existingInvoice, $vm->order?->order_num), + ]); + } + + $cycleMonths = OrderHelper::cycleToMonths($orderItem->billing_cycle); + $cycleDays = max(1, $cycleMonths * 30); + $remainingDays = $service->expires_at && $service->expires_at->isFuture() + ? now()->diffInDays($service->expires_at) + : 0; + $remainingDays = (int) max(1, min($cycleDays, $remainingDays)); + + try { + $invoice = DB::transaction(function () use ($service, $orderItem, $resolved, $remainingDays, $cycleDays) { + $total = 0; + $lines = []; + + foreach ($resolved as $entry) { + $unitPrice = $this->prorateUnitPrice((float) $entry['price'], $remainingDays, $cycleDays); + $totalPrice = $unitPrice * $entry['qty']; + $total += $totalPrice; + + $lines[] = [ + 'product_id' => $orderItem->product_id, + 'addon_id' => $entry['addon_id'], + 'type' => 'upgrade', + 'description' => 'Nâng cấp addon: ' . $entry['name'] . ' x' . $entry['qty'], + 'months' => 0, + 'quantity' => $entry['qty'], + 'unit_price' => $unitPrice, + 'total_price' => $totalPrice, + ]; + } + + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $service->customer_id, + 'order_id' => $orderItem->order_id, + 'service_id' => $service->id, + 'invoice_type' => self::INVOICE_TYPE_UPGRADE, + 'sub_total' => $total, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $total, + 'status' => InvoiceStatus::UNPAID->value, + 'due_date' => now()->addDays(3)->toDateString(), + ]); + + foreach ($lines as $line) { + InvoiceItem::create($line + ['invoice_id' => $invoice->id]); + } + + return $invoice; + }); + } catch (Throwable $e) { + Log::error('VmRebuildController: upgradeAddons invoice creation failed', [ + 'module_vmware_id' => $vm->id, + 'service_id' => $service->id, + 'error' => $e->getMessage(), + ]); + + return response()->json(['error' => true, 'message' => 'Không thể tạo yêu cầu nâng cấp, vui lòng thử lại sau.'], 500); } $this->activityLogger->log( - event: 'upgrade_addons', + event: 'upgrade_addons_requested', logName: 'VM', - description: "Customer {$customer->email} nâng cấp addons VM: {$vm->name} - áp dụng: " . implode(', ', $applied ?: ['none']), + description: "Customer {$customer->email} yêu cầu nâng cấp addons VM: {$vm->name}, hóa đơn #{$invoice->invoice_number}", subject: $vm, - newValues: array_filter(['cpu' => $payload['cpu'] ?? null, 'ram' => $payload['ram'] ?? null, 'disk' => $payload['disk'] ?? null]), + newValues: ['invoice_id' => $invoice->id, 'addons' => $resolved], ); return response()->json([ 'error' => false, - 'message' => 'Đã gửi yêu cầu nâng cấp addons', - 'data' => [ - 'applied' => $applied, - 'note' => 'bandwidth/gpu hiện được ghi nhận yêu cầu, chưa có luồng tự động áp vào VM', - ], + 'message' => 'Đã tạo hóa đơn nâng cấp. Vui lòng thanh toán để áp dụng vào VM.', + 'data' => $this->invoiceResponse($invoice, $vm->order?->order_num), ]); } + + private function prorateUnitPrice(float $price, int $remainingDays, int $cycleDays): int + { + $raw = $price * $remainingDays / $cycleDays; + + return (int) (ceil($raw / 1000) * 1000); + } + + private function invoiceResponse(Invoice $invoice, ?string $orderNum): array + { + return [ + 'order_id' => $invoice->order_id, + 'order_num' => $orderNum, + 'invoice_id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'total' => (float) $invoice->total, + 'status' => $invoice->status, + 'payment_url' => $orderNum ? '/hoa-don/' . $orderNum : null, + ]; + } } diff --git a/app/Http/Controllers/Api/Transaction/ManualDepositController.php b/app/Http/Controllers/Api/Transaction/ManualDepositController.php index b4b404b..6ced1db 100644 --- a/app/Http/Controllers/Api/Transaction/ManualDepositController.php +++ b/app/Http/Controllers/Api/Transaction/ManualDepositController.php @@ -182,6 +182,18 @@ class ManualDepositController extends Controller $user = $request->user(); + if ( + !str_starts_with($request->proof_image, "deposit-proofs/{$user->id}/") + || str_contains($request->proof_image, '..') + || !Storage::disk('local')->exists($request->proof_image) + || Transaction::where('proof_image', $request->proof_image)->exists() + ) { + return response()->json([ + 'error' => true, + 'message' => 'Chứng từ không hợp lệ', + ], 422); + } + $transaction = Transaction::where('uuid', $request->uuid) ->where('customer_id', $user->id) ->where('type', 'manual') @@ -254,14 +266,15 @@ class ManualDepositController extends Controller public function uploadProof(Request $request): JsonResponse { $request->validate([ - 'file' => 'required|image|mimes:jpeg,jpg,png|max:5120', + 'file' => 'required|image|mimes:jpeg,jpg,png,webp|max:5120', ]); - $path = $request->file('file')->store('deposit-proofs', 'public'); + $user = $request->user(); + + $path = $request->file('file')->store('deposit-proofs/' . $user->id, 'local'); return response()->json([ 'error' => false, - 'url' => Storage::disk('public')->url($path), 'path' => $path, ]); } @@ -288,7 +301,7 @@ class ManualDepositController extends Controller 'client_ip' => $request->ip(), 'transaction_code' => $transaction->public_code, 'amount' => $transaction->amount, - 'proof_image_url' => $transaction->proof_image ? Storage::disk('public')->url($transaction->proof_image) : null, + 'proof_image_url' => $transaction->proof_image_urls[0] ?? null, 'created_at' => now()->format('d/m/Y H:i:s'), ])); } catch (\Throwable $e) { diff --git a/app/Http/Controllers/admin/VCenterManagerController.php b/app/Http/Controllers/admin/VCenterManagerController.php index 86c2409..e230251 100644 --- a/app/Http/Controllers/admin/VCenterManagerController.php +++ b/app/Http/Controllers/admin/VCenterManagerController.php @@ -15,7 +15,7 @@ use App\Repositories\Module\IpAddress\IpAddressRepositoryInterface; use App\Repositories\Module\Network\NetworkRepositoryInterface; use App\Repositories\vCenter\vCenterRepositoryInterface; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Mail; use Yajra\DataTables\Facades\DataTables; @@ -495,7 +495,7 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $vCenterService->token ]) @@ -555,7 +555,7 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $vCenterService->token ]) @@ -603,7 +603,7 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $vCenterService->token]) ->post("https://{$vCenterService->apiUri}/rest/vcenter/vm/$vmwareId/power/reset"); @@ -652,7 +652,7 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $vCenterService->token]) ->post("https://{$vCenterService->apiUri}/rest/vcenter/vm/$vmwareId/snapshot", [ 'spec' => [ @@ -800,7 +800,7 @@ class VCenterManagerController extends Controller return response()->json(['error' => true, 'message' => 'Cannot login to vCenter'], 400); } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $vCenterService->token]) ->post("https://{$vCenterService->apiUri}/rest/vcenter/vm/$vmwareId/power/suspend"); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 73e416c..eac5406 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -91,6 +91,7 @@ class Kernel extends HttpKernel 'api.auth' => \App\Http\Middleware\ApiAuthCheck::class, 'client.access-token' => \App\Http\Middleware\ClientAccessTokenCheck::class, 'perm' => \App\Http\Middleware\CheckPermission::class, + 'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class, 'compression' => \App\Http\Middleware\CompressionMiddleware::class, 'compression.advanced' => \App\Http\Middleware\AdvancedCompressionMiddleware::class, ]; diff --git a/app/Http/Middleware/RestrictToLocalEnvironment.php b/app/Http/Middleware/RestrictToLocalEnvironment.php new file mode 100644 index 0000000..626deaa --- /dev/null +++ b/app/Http/Middleware/RestrictToLocalEnvironment.php @@ -0,0 +1,18 @@ +environment('local')) { + abort(404); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php b/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php index 98fbe1c..cfa225b 100644 --- a/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php +++ b/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Api\Client; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Password; class ClientChangePasswordRequest extends FormRequest { @@ -15,7 +16,7 @@ class ClientChangePasswordRequest extends FormRequest { return [ 'current_password' => 'required|string', - 'password' => 'required|string|min:8|confirmed|different:current_password', + 'password' => ['required', Password::min(10), 'confirmed', 'different:current_password'], ]; } @@ -24,7 +25,7 @@ class ClientChangePasswordRequest extends FormRequest return [ 'current_password.required' => 'Mật khẩu hiện tại là bắt buộc', 'password.required' => 'Mật khẩu mới là bắt buộc', - 'password.min' => 'Mật khẩu mới phải có ít nhất 8 ký tự', + 'password.min' => 'Mật khẩu mới phải có ít nhất 10 ký tự', 'password.confirmed' => 'Xác nhận mật khẩu không khớp', 'password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại', ]; diff --git a/app/Http/Requests/Api/Client/ClientRegisterRequest.php b/app/Http/Requests/Api/Client/ClientRegisterRequest.php index a390ed0..0d9a605 100644 --- a/app/Http/Requests/Api/Client/ClientRegisterRequest.php +++ b/app/Http/Requests/Api/Client/ClientRegisterRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Api\Client; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rules\Password; class ClientRegisterRequest extends FormRequest { @@ -40,8 +41,7 @@ class ClientRegisterRequest extends FormRequest 'password' => [ 'required', - 'string', - 'min:6', + Password::min(10), 'confirmed' ], @@ -67,7 +67,7 @@ class ClientRegisterRequest extends FormRequest 'email.unique' => 'Email đã tồn tại', 'password.required' => 'Mật khẩu là bắt buộc', - 'password.min' => 'Mật khẩu tối thiểu 6 ký tự', + 'password.min' => 'Mật khẩu tối thiểu 10 ký tự', 'password.confirmed' => 'Xác nhận mật khẩu không khớp', ]; } diff --git a/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php b/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php index 4ebf6d1..4f98b1f 100644 --- a/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php +++ b/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php @@ -15,8 +15,8 @@ class ClientUpdateCccdRequest extends FormRequest { return [ 'cmnd' => 'required|string|max:50', - 'cmnd_before' => 'required|image|max:5120', - 'cmnd_after' => 'required|image|max:5120', + 'cmnd_before' => 'required|mimes:jpg,jpeg,png,webp|max:5120', + 'cmnd_after' => 'required|mimes:jpg,jpeg,png,webp|max:5120', ]; } diff --git a/app/Http/Requests/Api/Order/RenewServiceRequest.php b/app/Http/Requests/Api/Order/RenewServiceRequest.php index bcb5546..a0e9203 100644 --- a/app/Http/Requests/Api/Order/RenewServiceRequest.php +++ b/app/Http/Requests/Api/Order/RenewServiceRequest.php @@ -16,7 +16,7 @@ class RenewServiceRequest extends FormRequest public function rules(): array { return [ - 'months' => ['required', 'integer', 'min:1', 'max:24'], + 'months' => ['required', 'integer', 'min:1', 'max:36'], ]; } @@ -26,7 +26,7 @@ class RenewServiceRequest extends FormRequest 'months.required' => 'Vui lòng chọn số tháng gia hạn.', 'months.integer' => 'Số tháng gia hạn không hợp lệ.', 'months.min' => 'Số tháng gia hạn tối thiểu là 1.', - 'months.max' => 'Số tháng gia hạn tối đa là 24.', + 'months.max' => 'Số tháng gia hạn tối đa là 36.', ]; } } diff --git a/app/Http/Requests/Api/Order/StoreOrderRequest.php b/app/Http/Requests/Api/Order/StoreOrderRequest.php index c9d79dd..9d22fd3 100644 --- a/app/Http/Requests/Api/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Api/Order/StoreOrderRequest.php @@ -23,7 +23,7 @@ class StoreOrderRequest extends FormRequest 'os_id' => 'nullable|integer', 'addons' => 'array', 'addons.*.addon_id' => 'nullable|exists:product_addon,id', - 'addons.*.qty' => 'nullable|integer|min:0', + 'addons.*.qty' => 'nullable|integer|min:0|max:1000000', ]; } @@ -38,6 +38,7 @@ class StoreOrderRequest extends FormRequest 'os_id.exists' => 'Hệ điều hành không hợp lệ', 'addons.*.addon_id.exists' => 'Addon không tồn tại', 'addons.*.qty.min' => 'Số lượng addon phải lớn hơn 0', + 'addons.*.qty.max' => 'Số lượng addon vượt quá giới hạn cho phép', ]; } } diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php index c840fc5..cf595d5 100644 --- a/app/Http/Resources/Api/Admin/InvoiceResource.php +++ b/app/Http/Resources/Api/Admin/InvoiceResource.php @@ -15,6 +15,7 @@ class InvoiceResource extends JsonResource 'order_num' => $this->whenLoaded('order', fn() => $this->order?->order_num), 'customer' => $this->whenLoaded('customer', fn() => [ 'id' => $this->customer->id, + 'uuid' => $this->customer->uuid, 'fullname' => $this->customer->fullname, 'email' => $this->customer->email, ]), diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 4a1b49f..67e4feb 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -90,6 +90,7 @@ class ServiceClientDetailResource extends JsonResource 'total' => (float) $order->total, 'months' => (int) $orderItem->months, 'billing_label' => $orderItem->billing_label, + 'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1), ] : null, 'vm' => $vm ? [ 'id' => $vm->id, diff --git a/app/Http/Resources/Api/Service/ServiceResource.php b/app/Http/Resources/Api/Service/ServiceResource.php index d32dbae..09757e5 100644 --- a/app/Http/Resources/Api/Service/ServiceResource.php +++ b/app/Http/Resources/Api/Service/ServiceResource.php @@ -15,33 +15,33 @@ class ServiceResource extends JsonResource public function toArray(Request $request): array { $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semiannually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'biennially' => '2 năm', - 'triennially' => '3 năm', - 'onetime' => 'Một lần', + 'monthly' => 'Hàng tháng', + 'quarterly' => 'Hàng quý', + 'semi_annually' => 'Nửa năm', + 'annually' => 'Hàng năm', + 'biennially' => '2 năm', + 'triennially' => '3 năm', + 'onetime' => 'Một lần', ]; $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semiannually' => 'semi_year', - 'annually' => 'year', - 'biennially' => 'biennially', - 'triennially' => 'triennially', - 'onetime' => 'none', + 'monthly' => 'month', + 'quarterly' => 'quarter', + 'semi_annually' => 'semi_year', + 'annually' => 'year', + 'biennially' => 'biennially', + 'triennially' => 'triennially', + 'onetime' => 'none', ]; $periodMonths = [ - 'monthly' => 1, - 'quarterly' => 3, - 'semiannually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - 'onetime' => 0, + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + 'onetime' => 0, ]; return [ diff --git a/app/Http/Services/Hosting.php b/app/Http/Services/Hosting.php index e5af8f1..8095801 100644 --- a/app/Http/Services/Hosting.php +++ b/app/Http/Services/Hosting.php @@ -5,7 +5,7 @@ namespace App\Http\Services; use App\Models\ModuleHosting; use App\Support\Eloquent\Sluggable; use Illuminate\Support\Facades\Crypt; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; @@ -27,7 +27,7 @@ class Hosting if ($apiCheck) { - return Http::withoutVerifying() + return TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -47,7 +47,7 @@ class Hosting Log::info('Không tồn tại apiKey'); return false; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -64,7 +64,7 @@ class Hosting */ public function LoginServer(): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($this->username, $this->password) ->acceptJson() ->post("https://$this->url/api/v2/auth/keys", @@ -127,7 +127,7 @@ class Hosting */ public function getAllClients() { - return Http::withoutVerifying() + return TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -137,7 +137,7 @@ class Hosting public function CreateNewClient($product_data = [], $password = ''): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -202,7 +202,7 @@ class Hosting if ($apiCheck) { - $allDomain = Http::withoutVerifying() + $allDomain = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -234,7 +234,7 @@ class Hosting if ($apiCheck) { - $CreateDomain = Http::withoutVerifying() + $CreateDomain = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -276,7 +276,7 @@ class Hosting public function CreateExpirationForDomain($domain = '', $date_exp = '') { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -303,7 +303,7 @@ class Hosting public function SuspendAccount($account_id = 0): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -322,7 +322,7 @@ class Hosting public function ActivateAccount($account_id = 0): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' @@ -341,7 +341,7 @@ class Hosting public function ResetPassword($account_id = 0, $password = ''): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withHeaders([ 'X-API-Key' => $this->apiKey, 'accept' => 'application/json' diff --git a/app/Http/Services/SignRSA.php b/app/Http/Services/SignRSA.php index f1f82dd..8ed9cb3 100644 --- a/app/Http/Services/SignRSA.php +++ b/app/Http/Services/SignRSA.php @@ -7,13 +7,16 @@ class SignRSA protected string $key; protected ?string $private; protected string $public; + protected ?string $previousKey; + protected ?string $previousPublic; public function __construct() { $this->key = base64_decode(config('rsa.key')); $this->private = str_replace('\n', "\n", config('rsa.private')); $this->public = str_replace('\n', "\n", config('rsa.public')); - + $this->previousKey = config('rsa.previous_key') ? base64_decode(config('rsa.previous_key')) : null; + $this->previousPublic = config('rsa.previous_public') ? str_replace('\n', "\n", config('rsa.previous_public')) : null; } @@ -49,9 +52,14 @@ class SignRSA if (!$data) { return false; } - $decrypted_data = $this->DecryptData(base64_decode($data), $this->key); + $encrypted_data = base64_decode($data); + $verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->key), $this->public); - return $this->Verify_Sign($decrypted_data); + if ($verified === false && $this->previousKey && $this->previousPublic) { + $verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->previousKey), $this->previousPublic); + } + + return $verified; } private function DecryptData($encrypted_data, $random_key): bool|string @@ -59,7 +67,7 @@ class SignRSA return openssl_decrypt($encrypted_data, 'DES-EDE3', $random_key, OPENSSL_RAW_DATA); } - private function Verify_Sign($decrypted_data): array|string|false + private function Verify_Sign(string $decrypted_data, string $public): string|false { $data_arr = explode('|', $decrypted_data); @@ -67,19 +75,15 @@ class SignRSA return false; } - if (!isset($data_arr[1])) { - return false; - } - $signature = base64_decode($data_arr[1]); unset($data_arr[1]); $signature_data = implode('|', $data_arr); - $verify = openssl_verify($signature_data, $signature, $this->public, 'sha256WithRSAEncryption'); + $verify = openssl_verify($signature_data, $signature, $public, 'sha256WithRSAEncryption'); - return $verify ? $signature_data : false; + return $verify === 1 ? $signature_data : false; } } diff --git a/app/Http/Services/Telegram.php b/app/Http/Services/Telegram.php index 490556f..195e8ea 100644 --- a/app/Http/Services/Telegram.php +++ b/app/Http/Services/Telegram.php @@ -35,7 +35,7 @@ class Telegram try { $url = "https://api.telegram.org/bot$this->token/sendMessage?parse_mode=html&chat_id=$this->group&text=" . urlencode($message); - $response = Http::withoutVerifying()->get($url); + $response = Http::get($url); // Log response để debug Log::channel('Custom')->info('Telegram sendMessage URL: ' . $url); @@ -234,8 +234,7 @@ class Telegram 'contents' => $caption, ]; } - $response = Http::withoutVerifying() - ->attach('photo', fopen($fullPath, 'r'), basename($fullPath)) + $response = Http::attach('photo', fopen($fullPath, 'r'), basename($fullPath)) ->post("https://api.telegram.org/bot{$this->token}/sendPhoto", [ 'chat_id' => $this->group, 'caption' => $caption, diff --git a/app/Http/Services/WinRMClient.php b/app/Http/Services/WinRMClient.php deleted file mode 100644 index 2c748c1..0000000 --- a/app/Http/Services/WinRMClient.php +++ /dev/null @@ -1,253 +0,0 @@ -ip = $ip; - $this->username = $username; - $this->password = $password; - $this->url = "http://{$this->ip}:53389/wsman"; - $this->auth = base64_encode("{$this->username}:{$this->password}"); - } - - private function send_winrm_request($headers, $body) - { - try { - // Make the HTTP POST request - $response = Http::withHeaders($headers) - ->withBasicAuth('administrator', 'Thegioiso@123**$$8899TgTk2') - ->timeout(20) // Timeout in seconds - ->withBody($body, 'application/soap+xml;charset=UTF-8') // Ensure the correct content type - ->withoutVerifying() // Disable SSL verification - ->post($this->url); - - // Check for a successful response - if ($response->successful()) { - return $response->body(); - } - // Handle response errors - return 'Error: ' . $response->status() . ' - ' . $response->body(); - } catch (\Exception $e) { - // Handle exceptions - return 'Exception: ' . $e->getMessage(); - } - } - - private function get_headers() - { - return [ - "Host: {$this->ip}:53389", - "User-Agent: PHP WinRM client", - "Accept-Encoding: gzip, deflate, br", - "Accept: */*", - "Connection: keep-alive", - "Content-Type: application/soap+xml;charset=UTF-8", - "Authorization: Basic {$this->auth}" - ]; - } - - public function execute_command($command) - { - $headers = $this->get_headers(); - - // Step 1: Create Shell - $create_shell_body = $this->get_create_shell_body(); - $response = $this->send_winrm_request($headers, $create_shell_body); - $shell_id = preg_match('/(.*?)<\/rsp:ShellId>/s', $response, $matches) ? $matches[1] : null; - - if (!$shell_id) { - return "Failed to create shell session."; - } - - // Step 2: Execute Command - $execute_command_body = $this->get_execute_command_body($shell_id, $command); - $response = $this->send_winrm_request($headers, $execute_command_body); - $command_id = preg_match('/(.*?)<\/rsp:CommandId>/s', $response, $matches) ? $matches[1] : null; - - if (!$command_id) { - return "Failed to execute command."; - } - - // Step 3: Receive Command Output - $receive_output_body = $this->get_receive_output_body($shell_id, $command_id); - $response = $this->send_winrm_request($headers, $receive_output_body); - $output = $this->parse_command_output($response, $command_id); - - // Step 4: Signal Termination - $signal_termination_body = $this->get_signal_termination_body($shell_id, $command_id); - $this->send_winrm_request($headers, $signal_termination_body); - - // Step 5: Delete Shell Session - $delete_shell_body = $this->get_delete_shell_body($shell_id); - $this->send_winrm_request($headers, $delete_shell_body); - - return $output; - } - - private function get_create_shell_body() - { - // XML body for creating a shell - return ' - - - http://' . $this->ip . ':53389/wsman - - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - - 153600 - uuid:' . uniqid() . ' - - - PT60S - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd - http://schemas.xmlsoap.org/ws/2004/09/transfer/Create - - FALSE - 437 - - - - - stdin - stdout stderr - - - '; - } - - private function get_execute_command_body($shell_id, $command) - { - // XML body for executing a command - return ' - - - http://' . $this->ip . ':53389/wsman - - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - - 153600 - uuid:' . uniqid() . ' - - - PT60S - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Command - - ' . $shell_id . ' - - - - - ' . $command . ' - - - '; - } - - private function get_receive_output_body($shell_id, $command_id) - { - // XML body for receiving command output - return ' - - - http://' . $this->ip . ':53389/wsman - - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - - 153600 - uuid:' . uniqid() . ' - - - PT60S - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Receive - - ' . $shell_id . ' - - - - - stdout stderr - - - '; - } - - private function parse_command_output($response, $command_id) - { - preg_match_all('/(.*?)<\/rsp:Stream>/s', $response, $matches); - $output = ''; - foreach ($matches[1] as $base64_output) { - $decoded = base64_decode($base64_output); - // Normalize line endings to \n - $decoded = preg_replace('/\r\n|\r/', "\n", $decoded); - $output .= $decoded; - } - return trim($output); // Remove any leading/trailing whitespace - } - - private function get_signal_termination_body($shell_id, $command_id) - { - // XML body for signaling termination - return ' - - - http://' . $this->ip . ':53389/wsman - - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - - 153600 - uuid:' . uniqid() . ' - - - PT60S - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/Signal - - ' . $shell_id . ' - - - - - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/signal/terminate - - - '; - } - - private function get_delete_shell_body($shell_id) - { - // XML body for deleting the shell session - return ' - - - http://' . $this->ip . ':53389/wsman - - http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous - - 153600 - uuid:' . uniqid() . ' - - - PT60S - http://schemas.microsoft.com/wbem/wsman/1/windows/shell/cmd - http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete - - ' . $shell_id . ' - - - - '; - } -} diff --git a/app/Http/Services/vCenter.php b/app/Http/Services/vCenter.php index f06dd4a..d9c6449 100644 --- a/app/Http/Services/vCenter.php +++ b/app/Http/Services/vCenter.php @@ -2,7 +2,6 @@ namespace App\Http\Services; -use Illuminate\Support\Facades\Log; use App\Http\Services\vCenter\Client; use App\Http\Services\vCenter\PowerManagementService; use App\Http\Services\vCenter\VirtualMachineService; @@ -12,8 +11,6 @@ use App\Http\Services\vCenter\TemplateService; use App\Http\Services\vCenter\VMConfigurationService; use App\Http\Services\vCenter\PowerShellService; use App\Http\Services\vCenter\Concerns\ManagesSsh; -use Exception; -use phpseclib3\Net\SSH2; class vCenter { @@ -26,7 +23,6 @@ class vCenter private int $diskCapacity = 1073741824; private int $memory = 1024; private ?string $os = null; - private SSH2|null $process = null; private Client $client; private PowerManagementService $power; @@ -339,179 +335,4 @@ class vCenter { return $this->shell->getWindowsRdpPort($vmwareId, $guestUser, $guestPassword); } - - // ─── Legacy VPS Direct SSH ──────────────────────────────────────────────── - // These methods use direct SSH to guest VM, not via jumphost. - - /** Look up host/user/password for a legacy target from infra_management, decrypting the stored password. */ - private function resolveLegacyInfra(string $type): ?array - { - $infra = \App\Models\InfraManagement::where('type', $type) - ->where('status', true) - ->first(); - - if (!$infra) { - Log::error("Legacy VPS lookup failed: no active infra_management row for type={$type}."); - return null; - } - - return [ - 'ip' => $infra->host, - 'user' => $infra->username, - 'password' => \App\Http\Facade\SignRSA::DecryptFunc($infra->password), - ]; - } - - protected function loginVPS(array|null $data = []): ?SSH2 - { - try { - $ip = $data['ip'] ?? null; - $user = $data['user'] ?? null; - $password = $data['password'] ?? null; - - if ($ip === null || $user === null || $password === null) { - $infra = $this->resolveLegacyInfra('legacy_vps'); - if (!$infra) { - throw new Exception('No legacy VPS credentials available (data not provided and no active infra_management row).'); - } - $ip ??= $infra['ip']; - $user ??= $infra['user']; - $password ??= $infra['password']; - } - - $this->process = new SSH2($ip, 22, 10); - if (!$this->process->login($user, $password)) { - throw new Exception("Cannot login to VPS with IP: $ip, User: $user"); - } - return $this->process; - } catch (Exception $exception) { - Log::error('Login VPS failed: ' . $exception->getMessage()); - $this->process = null; - return null; - } - } - - public function change_password_linux(array $data = ['new_password' => '']): bool - { - try { - if (empty($data['new_password'])) { - throw new Exception('New password cannot be empty.'); - } - if (!isset($data['ip'], $data['user'], $data['password'])) { - $infra = $this->resolveLegacyInfra('jumphost'); - if (!$infra) { - throw new Exception('No jumphost credentials available in infra_management.'); - } - $data += $infra; - } - $this->loginVPS($data); - if (!$this->process) { - throw new Exception('Cannot login to VPS.'); - } - $username = $data['user'] ?? 'root'; - $newPassword = $data['new_password']; - $this->process->exec('echo ' . escapeshellarg("$username:$newPassword") . ' | chpasswd'); - return true; - } catch (Exception $exception) { - Log::error('Failed to change password: ' . $exception->getMessage()); - return false; - } - } - - public function change_password_windows(array $data = ['new_password' => '']): bool - { - try { - if (empty($data['new_password'])) { - throw new Exception('New password cannot be empty.'); - } - if (!isset($data['ip'], $data['user'], $data['password'])) { - $infra = $this->resolveLegacyInfra('legacy_vps'); - if (!$infra) { - throw new Exception('No legacy VPS credentials available in infra_management.'); - } - $data += $infra; - } - $this->loginVPS($data); - if (!$this->process) { - throw new Exception('Cannot login to VPS.'); - } - $username = $data['user'] ?? 'administrator'; - $newPassword = $data['new_password']; - $response = $this->process->exec("net user \"$username\" \"$newPassword\""); - if (stripos($response, 'successfully') === false) { - throw new Exception("Failed to change password. Response: $response"); - } - return true; - } catch (Exception $exception) { - Log::error('Failed to change password: ' . $exception->getMessage()); - return false; - } - } - - public function extend_disk_centos_7(): bool - { - try { - $this->loginVPS(); - if (!$this->process) { - throw new Exception('Cannot login to VPS'); - } - $checkDisk = $this->process->exec("lsblk | grep -w 'sda2'"); - if (empty(trim($checkDisk))) { - throw new Exception('Disk /dev/sda2 not found'); - } - $this->process->exec('sudo tee /sys/class/block/sda/device/rescan'); - $this->process->exec('sudo pvcreate /dev/sda2'); - $this->process->exec('sudo vgextend /dev/centos /dev/sda2'); - $this->process->exec('sudo lvextend /dev/centos/root /dev/sda2'); - $this->process->exec('sudo xfs_growfs /dev/centos/root'); - return true; - } catch (Exception $exception) { - Log::error('Failed to extend disk: ' . $exception->getMessage()); - return false; - } - } - - public function extend_disk_centos_8(): bool - { - try { - $this->loginVPS(); - if (!$this->process) { - throw new Exception('Cannot login to VPS'); - } - $checkDisk = $this->process->exec("lsblk | grep -w 'sda3'"); - if (empty(trim($checkDisk))) { - throw new Exception('Disk /dev/sda3 not found'); - } - $this->process->exec('sudo pvcreate /dev/sda3'); - $this->process->exec('sudo vgextend /dev/cl /dev/sda3'); - $this->process->exec('sudo lvextend /dev/cl/root /dev/sd3'); - $this->process->exec('sudo xfs_growfs /dev/cl/root'); - return true; - } catch (Exception $exception) { - Log::error('Failed to extend disk: ' . $exception->getMessage()); - return false; - } - } - - public function extend_disk_ubuntu(): bool - { - try { - $this->loginVPS(); - if (!$this->process) { - throw new Exception('Cannot login to VPS'); - } - $checkDisk = $this->process->exec("lsblk | grep -w 'sda3'"); - if (empty(trim($checkDisk))) { - throw new Exception('Disk /dev/sda3 not found'); - } - $this->process->exec('sudo tee /sys/class/block/sda/device/rescan'); - $this->process->exec('sudo growpart /dev/sda 3'); - $this->process->exec('sudo pvresize /dev/sda3'); - $this->process->exec('sudo lvresize -l+100%FREE --resizefs /dev/mapper/ubuntu--vg-ubuntu--lv'); - return true; - } catch (Exception $exception) { - Log::error('Failed to extend disk: ' . $exception->getMessage()); - return false; - } - } } diff --git a/app/Http/Services/vCenter/Client.php b/app/Http/Services/vCenter/Client.php index fc287a9..2d26356 100644 --- a/app/Http/Services/vCenter/Client.php +++ b/app/Http/Services/vCenter/Client.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use App\Http\Facade\SignRSA; use App\Models\Vcenter; use Exception; @@ -23,7 +24,7 @@ class Client $this->username = $vcenterModel->user; $this->password = SignRSA::DecryptFunc($vcenterModel->password); - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withBasicAuth($this->username, $this->password) ->post("https://{$this->apiUri}/rest/com/vmware/cis/session", []); @@ -48,7 +49,7 @@ class Client public function checkApiExits(): bool { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->token ]) diff --git a/app/Http/Services/vCenter/Concerns/ManagesSsh.php b/app/Http/Services/vCenter/Concerns/ManagesSsh.php index 6d7bc01..e13fd9b 100644 --- a/app/Http/Services/vCenter/Concerns/ManagesSsh.php +++ b/app/Http/Services/vCenter/Concerns/ManagesSsh.php @@ -4,6 +4,9 @@ namespace App\Http\Services\vCenter\Concerns; use App\Http\Facade\SignRSA; use App\Models\InfraManagement; +use App\Support\PowerCliCertificatePolicy; +use App\Support\PowerShellEscaper; +use App\Support\SshHostKeyVerifier; use Exception; use Illuminate\Support\Facades\Log; use phpseclib3\Net\SSH2; @@ -19,6 +22,11 @@ use phpseclib3\Net\SSH2; */ trait ManagesSsh { + private function powerCliCertificateLine(): string + { + return PowerCliCertificatePolicy::configurationLine(); + } + // ─── Auth ──────────────────────────────────────────────────────────────── private function LoginAuth(): SSH2|false @@ -41,6 +49,8 @@ trait ManagesSsh $connection = new SSH2($sshHost, $sshPort); + SshHostKeyVerifier::verify($connection, $sshHost); + if ($connection->login($infra->username, SignRSA::DecryptFunc($infra->password))) { $connection->setTimeout(0); return $connection; @@ -83,7 +93,7 @@ trait ManagesSsh try { $psCommand = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "' . 'Import-Module VMware.PowerCLI; ' - . 'Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -Scope Session; ' + . $this->powerCliCertificateLine() . '; ' . '$cred = Import-Clixml -Path \'C:\secure\vcenter.cred\'; ' . 'Connect-VIServer -Server \'' . $this->apiUri . '\' -Credential $cred; ' . $cmd . '; ' @@ -113,8 +123,8 @@ trait ManagesSsh return 'Login_Fail'; } - $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' - . '-Command "' . str_replace('"', '\"', $psCommand) . '"'; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($psCommand); try { $out = $auth->exec($action); @@ -136,7 +146,6 @@ trait ManagesSsh return 'Login_Fail'; } - $esc = fn(string $v) => str_replace("'", "''", $v); $script = strtr( <<<'PS' $ProgressPreference = 'SilentlyContinue' @@ -145,7 +154,7 @@ $WarningPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null + Connect-VIServer -Server '' -User '' -Password '' | Out-Null & { @@ -153,15 +162,16 @@ Connect-VIServer -Server '' -User '' -Password '' | Out-Null } | Out-String PS, [ - '' => $esc($this->apiUri ?? ''), - '' => $esc($this->username ?? ''), - '' => $esc($this->password ?? ''), + '' => $this->powerCliCertificateLine(), + '' => PowerShellEscaper::singleQuote($this->apiUri ?? ''), + '' => PowerShellEscaper::singleQuote($this->username ?? ''), + '' => PowerShellEscaper::singleQuote($this->password ?? ''), '' => $cmd, ] ); - $encoded = base64_encode(mb_convert_encoding($script, 'UTF-16LE', 'UTF-8')); - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($script); try { $out = $auth->exec($action); @@ -183,7 +193,6 @@ PS, return 'Login_Fail'; } - $esc = fn(string $v) => str_replace("'", "''", $v); $script = strtr( <<<'PS' $ProgressPreference = 'SilentlyContinue' @@ -193,7 +202,7 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null + Connect-VIServer -Server '' -User '' -Password '' | Out-Null & { @@ -201,15 +210,16 @@ Connect-VIServer -Server '' -User '' -Password '' | Out-Null } PS, [ - '' => $esc($this->apiUri ?? ''), - '' => $esc($this->username ?? ''), - '' => $esc($this->password ?? ''), + '' => $this->powerCliCertificateLine(), + '' => PowerShellEscaper::singleQuote($this->apiUri ?? ''), + '' => PowerShellEscaper::singleQuote($this->username ?? ''), + '' => PowerShellEscaper::singleQuote($this->password ?? ''), '' => $cmd, ] ); - $encoded = base64_encode(mb_convert_encoding($script, 'UTF-16LE', 'UTF-8')); - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($script); try { $out = $auth->exec($action); diff --git a/app/Http/Services/vCenter/PowerManagementService.php b/app/Http/Services/vCenter/PowerManagementService.php index a961bff..b8f6ea2 100644 --- a/app/Http/Services/vCenter/PowerManagementService.php +++ b/app/Http/Services/vCenter/PowerManagementService.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; class PowerManagementService @@ -17,7 +18,7 @@ class PowerManagementService public function SuspendVirtualMachine($vmware_id = ''): array|string { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -39,7 +40,7 @@ class PowerManagementService public function RebootVirtualMachine($product, $vmware_id = ''): array|string { if ($this->client->loginServer($product->ipaddress->host->datacenter->vCenter)) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -61,7 +62,7 @@ class PowerManagementService public function StartVirtualMachine($product, $vmware_id = ''): array|string { if ($this->client->loginServer($product->ipaddress->host->datacenter->vCenter)) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -83,7 +84,7 @@ class PowerManagementService public function ShutdownGuestVirtualMachine($product, $vmware_id = ''): array|string { if ($this->client->loginServer($product->ipaddress->host->datacenter->vCenter)) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -106,7 +107,7 @@ class PowerManagementService public function PowerOffVirtualMachine($vmware_id = ''): array|string { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -128,7 +129,7 @@ class PowerManagementService public function DeleteVirtualMachine($vmware_id = ''): array|string { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -150,7 +151,7 @@ class PowerManagementService public function GetConsoleTickets($vmware_id = ''): array|string { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -219,7 +220,7 @@ class PowerManagementService return "Login_Again"; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -265,7 +266,7 @@ class PowerManagementService return 'Login_Again'; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $this->client->token]) ->timeout(8) ->get("https://{$this->client->apiUri}/rest/vcenter/vm/$vmId/power"); diff --git a/app/Http/Services/vCenter/PowerShellService.php b/app/Http/Services/vCenter/PowerShellService.php index 3a0742c..6bfd695 100644 --- a/app/Http/Services/vCenter/PowerShellService.php +++ b/app/Http/Services/vCenter/PowerShellService.php @@ -7,6 +7,9 @@ use phpseclib3\Net\SSH2; use Exception; use App\Models\InfraManagement; use App\Http\Facade\SignRSA; +use App\Support\PowerShellEscaper; +use App\Support\PowerCliCertificatePolicy; +use App\Support\SshHostKeyVerifier; class PowerShellService { @@ -41,6 +44,8 @@ class PowerShellService $connection = new SSH2($sshHost, $sshPort); + SshHostKeyVerifier::verify($connection, $sshHost); + if ($connection->login($sshUser, $sshPass)) { $connection->setTimeout(0); // No timeout return $connection; @@ -72,9 +77,8 @@ class PowerShellService return "Login_Fail"; } - // Action wrapper - $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' - . '-Command "' . str_replace('"', '\"', $psCommand) . '"'; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($psCommand); try { $out = $auth->exec($action); @@ -91,6 +95,11 @@ class PowerShellService return $this->CommandRSP($cmd); } + private function powerCliCertificateLine(): string + { + return PowerCliCertificatePolicy::configurationLine(); + } + protected function CommandRSP(string $cmd) { $auth = $this->LoginAuth(); @@ -98,12 +107,9 @@ class PowerShellService return "Login_Fail"; } - $esc = fn(string $v) => str_replace("'", "''", $v); - - // Get credentials from Client - $server = $esc($this->client->apiUri ?? ''); - $user = $esc($this->client->getUsername() ?? ''); - $pass = $esc($this->client->getPassword() ?? ''); + $server = PowerShellEscaper::singleQuote($this->client->apiUri ?? ''); + $user = PowerShellEscaper::singleQuote($this->client->getUsername() ?? ''); + $pass = PowerShellEscaper::singleQuote($this->client->getPassword() ?? ''); $psTemplate = <<<'PS' $ProgressPreference = 'SilentlyContinue' @@ -112,7 +118,7 @@ $WarningPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null + Connect-VIServer -Server '' -User '' -Password '' | Out-Null & { @@ -121,19 +127,19 @@ Connect-VIServer -Server '' -User '' -Password '' | Out-Null PS; $psScript = strtr($psTemplate, [ + '' => $this->powerCliCertificateLine(), '' => $server, '' => $user, '' => $pass, '' => $cmd, ]); - $encoded = base64_encode(mb_convert_encoding($psScript, 'UTF-16LE', 'UTF-8')); - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($psScript); try { $out = $auth->exec($action); $errOut = $auth->getStdError(); - // PowerCLI banner is CLIXML informational records — not an error $realErr = $errOut && !str_starts_with(trim($errOut), '#< CLIXML'); return $out . ($realErr ? "\n[STDERR]\n" . $errOut : ''); } catch (\Throwable $e) { @@ -148,11 +154,9 @@ PS; return "Login_Fail"; } - $esc = fn(string $v) => str_replace("'", "''", $v); - - $server = $esc($this->client->apiUri ?? ''); - $user = $esc($this->client->getUsername() ?? ''); - $pass = $esc($this->client->getPassword() ?? ''); + $server = PowerShellEscaper::singleQuote($this->client->apiUri ?? ''); + $user = PowerShellEscaper::singleQuote($this->client->getUsername() ?? ''); + $pass = PowerShellEscaper::singleQuote($this->client->getPassword() ?? ''); $psTemplate = <<<'PS' $ProgressPreference = 'SilentlyContinue' @@ -162,7 +166,7 @@ $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 Import-Module VMware.PowerCLI -ErrorAction Stop -Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Scope Session -Confirm:$false | Out-Null + Connect-VIServer -Server '' -User '' -Password '' | Out-Null & { @@ -171,14 +175,15 @@ Connect-VIServer -Server '' -User '' -Password '' | Out-Null PS; $psScript = strtr($psTemplate, [ + '' => $this->powerCliCertificateLine(), '' => $server, '' => $user, '' => $pass, '' => $cmd, ]); - $encoded = base64_encode(mb_convert_encoding($psScript, 'UTF-16LE', 'UTF-8')); - $action = "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encoded"; + $action = 'powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($psScript); try { $out = $auth->exec($action); @@ -201,15 +206,14 @@ PS; public function resetGuestPasswordViaPs1(string $vmName, string $guestUser, string $guestPassword, string $newPassword, string $targetUser = 'Administrator'): array|string { $psFile = "C:\\CLISave\\AssignIP\\SetGuestPwd.ps1"; - $q = fn(string $v) => str_replace("'", "''", $v); - $args = " -VMName '" . $q($vmName) . "'"; - $args .= " -GuestUser '" . $q($guestUser) . "'"; - $args .= " -GuestPassword '" . $q($guestPassword) . "'"; - $args .= " -TargetUser '" . $q($targetUser) . "'"; - $args .= " -NewPassword '" . $q($newPassword) . "'"; + $args = ' -VMName ' . PowerShellEscaper::singleQuote($vmName); + $args .= ' -GuestUser ' . PowerShellEscaper::singleQuote($guestUser); + $args .= ' -GuestPassword ' . PowerShellEscaper::singleQuote($guestPassword); + $args .= ' -TargetUser ' . PowerShellEscaper::singleQuote($targetUser); + $args .= ' -NewPassword ' . PowerShellEscaper::singleQuote($newPassword); - $cmd = "& '$psFile'$args | Out-String"; + $cmd = '& ' . PowerShellEscaper::singleQuote($psFile) . "{$args} | Out-String"; $raw = $this->CommandRSP($cmd); if ($raw === 'Login_Fail' || $raw === false || $raw === null) { @@ -230,14 +234,13 @@ PS; public function changeRDPPortWindows(string $vmName, string $guestUser, string $guestPassword, string $newPort): array|string { $psFile = "C:\\CLISave\\AssignIP\\change-rdp-port.ps1"; - $q = fn(string $v) => str_replace("'", "''", $v); - $args = " -VMName '" . $q($vmName) . "'"; - $args .= " -GuestUser '" . $q($guestUser) . "'"; - $args .= " -GuestPassword '" . $q($guestPassword) . "'"; - $args .= " -NewPort " . (int) $newPort; + $args = ' -VMName ' . PowerShellEscaper::singleQuote($vmName); + $args .= ' -GuestUser ' . PowerShellEscaper::singleQuote($guestUser); + $args .= ' -GuestPassword ' . PowerShellEscaper::singleQuote($guestPassword); + $args .= ' -NewPort ' . (int) $newPort; - $cmd = "& '$psFile'$args"; + $cmd = '& ' . PowerShellEscaper::singleQuote($psFile) . $args; $raw = $this->CommandChangePort($cmd, true); @@ -286,8 +289,6 @@ PS; string $dns = '8.8.8.8', string $hostname = '' ): string { - $q = fn(string $v) => str_replace("'", "''", $v); - $isLinux = str_contains(strtolower($osType), 'linux') || str_contains(strtolower($osType), 'ubuntu') || str_contains(strtolower($osType), 'centos') @@ -295,29 +296,35 @@ PS; if ($isLinux) { $psFile = "C:\\CLISave\\changeIPLinux.ps1"; - $Command = "& '{$psFile}'" - . " -VMName '" . $q($vmName) . "'" - . " -IP '" . $q($ipAddress) . "'" - . " -Gateway '" . $q($gateway) . "'" - . " -DNS '" . $q($dns) . "'" - . " -GuestUser '" . $q($guestUser) . "'" - . " -GuestPassword '" . $q($guestPassword) . "'"; + $Command = '& ' . PowerShellEscaper::singleQuote($psFile) + . ' -VMName ' . PowerShellEscaper::singleQuote($vmName) + . ' -IP ' . PowerShellEscaper::singleQuote($ipAddress) + . ' -Gateway ' . PowerShellEscaper::singleQuote($gateway) + . ' -DNS ' . PowerShellEscaper::singleQuote($dns) + . ' -GuestUser ' . PowerShellEscaper::singleQuote($guestUser) + . ' -GuestPassword ' . PowerShellEscaper::singleQuote($guestPassword); - Log::channel('Custom')->info('[changeIpVM:linux] ' . $Command); + Log::channel('Custom')->info('[changeIpVM:linux]', [ + 'script' => 'changeIPLinux.ps1', + 'vmware_id' => $vmwareId, + ]); return (string) $this->CommandChangePort($Command, true); } $VirtualMachine = "VirtualMachine-$vmwareId"; $hn = strtolower($hostname ?: $vmName); - $Command = "\$vm = get-vm -Id '{$VirtualMachine}';" - . " \$hostname = '{$q($hn)}';" - . " \$ipadd = '{$q($ipAddress)}';" - . " \$gw = '{$q($gateway)}';" - . " \$password = \"'{$q($guestPassword)}'\"; " - . " cd /; C:\\CLISave\\changeWindows1line.ps1;"; + $Command = '$vm = get-vm -Id ' . PowerShellEscaper::singleQuote($VirtualMachine) . ';' + . ' $hostname = ' . PowerShellEscaper::singleQuote($hn) . ';' + . ' $ipadd = ' . PowerShellEscaper::singleQuote($ipAddress) . ';' + . ' $gw = ' . PowerShellEscaper::singleQuote($gateway) . ';' + . ' $password = ' . PowerShellEscaper::singleQuote($guestPassword) . '; ' + . ' cd /; C:\\CLISave\\changeWindows1line.ps1;'; - Log::channel('Custom')->info('[changeIpVM:windows] ' . $Command); + Log::channel('Custom')->info('[changeIpVM:windows]', [ + 'script' => 'changeWindows1line.ps1', + 'vmware_id' => $vmwareId, + ]); return (string) $this->CommandRSP($Command); } @@ -326,11 +333,11 @@ PS; { $ps = "(Get-ItemProperty 'HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp' -Name PortNumber).PortNumber"; - $cmd = "Invoke-VMScript -VM '$vmwareId' " - . "-ScriptText \"$ps\" " - . "-ScriptType Powershell " - . "-GuestUser '$guestUser' " - . "-GuestPassword '$guestPassword'"; + $cmd = 'Invoke-VMScript -VM ' . PowerShellEscaper::singleQuote($vmwareId) + . ' -ScriptText ' . PowerShellEscaper::singleQuote($ps) + . ' -ScriptType Powershell' + . ' -GuestUser ' . PowerShellEscaper::singleQuote($guestUser) + . ' -GuestPassword ' . PowerShellEscaper::singleQuote($guestPassword); $output = $this->CommandRSP($cmd); diff --git a/app/Http/Services/vCenter/ResourceService.php b/app/Http/Services/vCenter/ResourceService.php index 6850cff..5bafa2b 100644 --- a/app/Http/Services/vCenter/ResourceService.php +++ b/app/Http/Services/vCenter/ResourceService.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; class ResourceService @@ -17,7 +18,7 @@ class ResourceService public function getAllDatacenter() { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -34,7 +35,7 @@ class ResourceService public function getAllHost($datacenter = '') { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -53,7 +54,7 @@ class ResourceService public function getAllFolder($datacenter = '') { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -80,7 +81,7 @@ class ResourceService $params['filter.datacenters'] = $datacenter; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -115,7 +116,7 @@ class ResourceService $params['filter.datacenters'] = $datacenter; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -171,7 +172,7 @@ class ResourceService $params['filter.datacenters'] = $datacenter; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) diff --git a/app/Http/Services/vCenter/SnapshotService.php b/app/Http/Services/vCenter/SnapshotService.php index 80a8722..dc5592b 100644 --- a/app/Http/Services/vCenter/SnapshotService.php +++ b/app/Http/Services/vCenter/SnapshotService.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use App\Support\Cache\CacheKey; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -438,7 +439,7 @@ class SnapshotService ' . $soapBody . ' '; - curl_setopt_array($curl, array( + curl_setopt_array($curl, [ CURLOPT_URL => "https://$host/sdk", CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', @@ -449,10 +450,8 @@ class SnapshotService CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => $headers, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HEADER => true // We need headers for cookie - )); + ] + TlsHttpClientFactory::vCenterCurlOptions()); $response = curl_exec($curl); diff --git a/app/Http/Services/vCenter/TemplateService.php b/app/Http/Services/vCenter/TemplateService.php index dda4908..4f0841a 100644 --- a/app/Http/Services/vCenter/TemplateService.php +++ b/app/Http/Services/vCenter/TemplateService.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; class TemplateService { @@ -16,7 +17,7 @@ class TemplateService public function getAllTemplate() { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -32,7 +33,7 @@ class TemplateService public function getTemplatebyID($template = '') { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -61,7 +62,7 @@ class TemplateService public function getVersionDetail($version_id = '') { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -76,7 +77,7 @@ class TemplateService public function getAllVersion($library = ''): bool|array|string { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) diff --git a/app/Http/Services/vCenter/VirtualMachineService.php b/app/Http/Services/vCenter/VirtualMachineService.php index 83f39e3..d9c722a 100644 --- a/app/Http/Services/vCenter/VirtualMachineService.php +++ b/app/Http/Services/vCenter/VirtualMachineService.php @@ -3,6 +3,7 @@ namespace App\Http\Services\vCenter; use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Str; @@ -32,7 +33,7 @@ class VirtualMachineService $params['filter.hosts'] = $host; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -111,7 +112,7 @@ class VirtualMachineService try { Log::info('Bắt đầu clone VM với timeout 300 giây', ['template' => $template, 'target_name' => $body['spec']['name']]); - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->timeout(300) ->connectTimeout(30) ->withHeaders([ @@ -230,7 +231,7 @@ class VirtualMachineService 'hot_add_enabled' => true ] ]; - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->timeout(30) ->withHeaders([ 'vmware-api-session-id' => $this->client->token, @@ -264,7 +265,7 @@ class VirtualMachineService ] ]; - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->timeout(30) ->withHeaders([ 'vmware-api-session-id' => $this->client->token, @@ -299,7 +300,7 @@ class VirtualMachineService ] ]; - $diskResponse = Http::withoutVerifying() + $diskResponse = TlsHttpClientFactory::vCenter() ->timeout(30) ->withHeaders([ 'vmware-api-session-id' => $this->client->token, @@ -318,7 +319,7 @@ class VirtualMachineService $firstDisk = $diskData['value'][0]; $diskKey = $firstDisk['key']; - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->timeout(30) ->withHeaders([ 'vmware-api-session-id' => $this->client->token, @@ -340,7 +341,7 @@ class VirtualMachineService public function CheckVirtualMachine($vmware_id = '') { if ($this->client->checkApiExits()) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -358,7 +359,7 @@ class VirtualMachineService { $check = $this->client->checkApiExits(); if ($check) { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -383,7 +384,7 @@ class VirtualMachineService return null; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -406,7 +407,7 @@ class VirtualMachineService public function GetDetailVirtualMachine($vmware_id = ''): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -425,7 +426,7 @@ class VirtualMachineService public function GetIPAddressVirtualMachine($vmware_id = ''): array { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -451,7 +452,7 @@ class VirtualMachineService ]; try { - $hardwareResponse = Http::withoutVerifying() + $hardwareResponse = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -480,7 +481,7 @@ class VirtualMachineService } } - $guestMemoryResponse = Http::withoutVerifying() + $guestMemoryResponse = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -494,7 +495,7 @@ class VirtualMachineService : 0; } - $guestCpuResponse = Http::withoutVerifying() + $guestCpuResponse = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -512,7 +513,7 @@ class VirtualMachineService public function ConfigNetworkVirtualMachine($vmware_id = '', $config = []): int { - return Http::withoutVerifying() + return TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -526,7 +527,7 @@ class VirtualMachineService if ($check) { try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -552,7 +553,7 @@ class VirtualMachineService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -578,7 +579,7 @@ class VirtualMachineService $vmId = $vmList[0]['vm']; - $detailResponse = Http::withoutVerifying() + $detailResponse = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -655,7 +656,7 @@ class VirtualMachineService "size_MiB" => $ram * 1024, ] ]; - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) @@ -686,7 +687,7 @@ class VirtualMachineService "count" => $cpu ] ]; - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders([ 'vmware-api-session-id' => $this->client->token ]) diff --git a/app/Integrations/Domain/Writers/DnsDomainWriter.php b/app/Integrations/Domain/Writers/DnsDomainWriter.php index 0e47e02..b08e734 100644 --- a/app/Integrations/Domain/Writers/DnsDomainWriter.php +++ b/app/Integrations/Domain/Writers/DnsDomainWriter.php @@ -16,17 +16,33 @@ class DnsDomainWriter return false; } - DB::table('dns_manager_domain')->updateOrInsert( - ['legacy_id' => $data->legacyId], - [ - 'domain_name' => $data->domainName, - 'dns_manager_server_id' => $data->dnsManagerServerId, - 'is_created' => $data->isCreated, - 'plesk_id' => $data->pleskId, - 'created_at' => $data->createdAt, - 'updated_at' => $data->updatedAt, - ] - ); + $attributes = [ + 'legacy_id' => $data->legacyId, + 'domain_name' => $data->domainName, + 'dns_manager_server_id' => $data->dnsManagerServerId, + 'is_created' => $data->isCreated, + 'plesk_id' => $data->pleskId, + 'created_at' => $data->createdAt, + 'updated_at' => $data->updatedAt, + ]; + + $existingId = DB::table('dns_manager_domain')->where('legacy_id', $data->legacyId)->value('id'); + + // A WHMCS-created placeholder (DnsDomainLinker) has legacy_id NULL and the same + // domain_name — adopt it instead of inserting a duplicate row that would strand + // dns_domain_user pointing at the empty placeholder while NS records attach here. + if (! $existingId && $data->domainName !== null) { + $existingId = DB::table('dns_manager_domain') + ->whereNull('legacy_id') + ->whereRaw('LOWER(domain_name) = ?', [mb_strtolower($data->domainName)]) + ->value('id'); + } + + if ($existingId) { + DB::table('dns_manager_domain')->where('id', $existingId)->update($attributes); + } else { + DB::table('dns_manager_domain')->insert($attributes); + } return true; } diff --git a/app/Integrations/Whmcs/DTOs/ProductCategoryData.php b/app/Integrations/Whmcs/DTOs/ProductCategoryData.php index e94c94f..f93ba0e 100644 --- a/app/Integrations/Whmcs/DTOs/ProductCategoryData.php +++ b/app/Integrations/Whmcs/DTOs/ProductCategoryData.php @@ -6,6 +6,7 @@ class ProductCategoryData { public function __construct( public readonly int $legacyId, + public readonly ?int $adoptId, public readonly string $name, public readonly string $slug, public readonly ?string $type, diff --git a/app/Integrations/Whmcs/Support/SlugUniquifier.php b/app/Integrations/Whmcs/Support/SlugUniquifier.php index 0f572b4..0589a6d 100644 --- a/app/Integrations/Whmcs/Support/SlugUniquifier.php +++ b/app/Integrations/Whmcs/Support/SlugUniquifier.php @@ -5,7 +5,6 @@ namespace App\Integrations\Whmcs\Support; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -/** Slugifies a candidate and appends -N until it's unique in the given column, excluding the row's own legacy_id. */ class SlugUniquifier { public function unique(string $table, string $column, string $candidate, int $legacyId): string @@ -14,10 +13,27 @@ class SlugUniquifier $slug = $base; $suffix = 1; - while (DB::table($table)->where($column, $slug)->where('legacy_id', '!=', $legacyId)->exists()) { + while ($this->ownedByAnotherRow($table, $column, $slug, $legacyId)) { $slug = "{$base}-" . ++$suffix; } return $slug; } + + public function adoptableId(string $table, string $column, string $candidate): ?int + { + $slug = Str::slug($candidate) ?: 'item'; + + return DB::table($table)->where($column, $slug)->whereNull('legacy_id')->value('id'); + } + + private function ownedByAnotherRow(string $table, string $column, string $slug, int $legacyId): bool + { + return DB::table($table) + ->where($column, $slug) + ->where(function ($query) use ($legacyId) { + $query->whereNull('legacy_id')->orWhere('legacy_id', '!=', $legacyId); + }) + ->exists(); + } } diff --git a/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php b/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php index fc2982e..15ec5b7 100644 --- a/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php +++ b/app/Integrations/Whmcs/Transformers/ProductCategoryTransformer.php @@ -17,11 +17,17 @@ class ProductCategoryTransformer $row = (array) $row; $legacyId = (int) $row['id']; $name = $row['name'] ?: "WHMCS Group {$legacyId}"; + $candidate = $row['slug'] ?: $name; + + $adoptId = $this->slugs->adoptableId('product_categories', 'slug', $candidate); return new ProductCategoryData( legacyId: $legacyId, + adoptId: $adoptId, name: $name, - slug: $this->slugs->unique('product_categories', 'slug', $row['slug'] ?: $name, $legacyId), + slug: $adoptId + ? (Str::slug($candidate) ?: 'item') + : $this->slugs->unique('product_categories', 'slug', $candidate, $legacyId), type: $this->guessType($name), isActive: ! $row['hidden'], sortOrder: (int) $row['order'], diff --git a/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php b/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php index 50623a0..2a9f473 100644 --- a/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php +++ b/app/Integrations/Whmcs/Writers/ProductCategoryWriter.php @@ -10,8 +10,9 @@ class ProductCategoryWriter public function write(ProductCategoryData $data): void { DB::table('product_categories')->updateOrInsert( - ['legacy_id' => $data->legacyId], + $data->adoptId ? ['id' => $data->adoptId] : ['legacy_id' => $data->legacyId], [ + 'legacy_id' => $data->legacyId, 'name' => $data->name, 'slug' => $data->slug, 'type' => $data->type, diff --git a/app/Jobs/Mail/ExpiryMail.php b/app/Jobs/Mail/ExpiryMail.php index 87fc43b..a2b5603 100644 --- a/app/Jobs/Mail/ExpiryMail.php +++ b/app/Jobs/Mail/ExpiryMail.php @@ -60,7 +60,7 @@ class ExpiryMail implements ShouldQueue 'client_name' => $product->Customer->firstname . " " . $product->Customer->lastname, 'product_name' => $product->GetInvoiceItem()->GetItemProduct()->name, 'service_due_exp' => Carbon::parse($product->next_due_date)->format('d/m/Y'), - 'service_remaining' => Carbon::now()->diffInDays(Carbon::parse($product->next_due_date),false) + 1 + 'service_remaining' => (int) Carbon::now()->diffInDays(Carbon::parse($product->next_due_date), false) + 1 ]; try { diff --git a/app/Jobs/VirtualMachine/ChangeIpVMJob.php b/app/Jobs/VirtualMachine/ChangeIpVMJob.php index 6b113a5..6c0b729 100644 --- a/app/Jobs/VirtualMachine/ChangeIpVMJob.php +++ b/app/Jobs/VirtualMachine/ChangeIpVMJob.php @@ -7,6 +7,7 @@ namespace App\Jobs\VirtualMachine; use App\Actions\VM\ChangeIpVMAction; use App\Http\Services\Telegram; use App\Models\ModuleVmware; +use App\Services\VirtualMachine\VmIpReservationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -38,9 +39,11 @@ class ChangeIpVMJob implements ShouldQueue public readonly string $dns, public readonly string $guestUser, public readonly string $guestPassword, + public readonly ?int $newIpPoolId = null, + public readonly ?int $oldIpPoolId = null, ) {} - public function handle(ChangeIpVMAction $action): void + public function handle(ChangeIpVMAction $action, VmIpReservationService $ipReservation): void { $vm = ModuleVmware::with([ 'vCenter', @@ -49,6 +52,9 @@ class ChangeIpVMJob implements ShouldQueue if (!$vm) { Log::error('[ChangeIpVMJob] ModuleVmware not found', ['module_vmware_id' => $this->moduleVmwareId]); + if ($this->newIpPoolId) { + $ipReservation->releaseByPoolId($this->newIpPoolId); + } return; } @@ -62,6 +68,22 @@ class ChangeIpVMJob implements ShouldQueue $this->guestPassword, ); + $failed = $raw === 'Login_Again' + || $raw === 'vCenter_Not_Found' + || str_starts_with((string) $raw, 'Error:') + || str_contains((string) $raw, '[STDERR]'); + + if ($failed) { + if ($this->newIpPoolId) { + $ipReservation->releaseByPoolId($this->newIpPoolId); + } + } else { + $vm->update(['ip' => $this->ipAddress, 'dns' => $this->dns]); + if ($this->oldIpPoolId) { + $ipReservation->releaseByPoolId($this->oldIpPoolId); + } + } + Log::channel('Custom')->info('[ChangeIpVMJob] raw output', [ 'module_vmware_id' => $this->moduleVmwareId, 'ip_address' => $this->ipAddress, @@ -88,7 +110,7 @@ class ChangeIpVMJob implements ShouldQueue return; } - if (str_starts_with((string) $raw, 'Error:') || str_contains((string) $raw, '[STDERR]')) { + if ($failed) { $telegram->SendErrorMessage("❌ Đổi IP thất bại [{$this->osType}]\n" . "🖥 VM: {$vm->name} ({$vm->vmware_id})\n" . "📍 IP mới: {$this->ipAddress}\n" @@ -116,5 +138,9 @@ class ChangeIpVMJob implements ShouldQueue 'ip_address' => $this->ipAddress, 'error' => $e->getMessage(), ]); + + if ($this->newIpPoolId) { + app(VmIpReservationService::class)->releaseByPoolId($this->newIpPoolId); + } } } diff --git a/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php b/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php deleted file mode 100644 index 207576c..0000000 --- a/app/Jobs/VirtualMachine/Create/ChangePasswordViaSshJob.php +++ /dev/null @@ -1,192 +0,0 @@ - $this->moduleVmwareId, - 'ip' => $this->ip, - 'username' => $this->username, - ]); - - $output = $this->runWithRetry(); - - if ($output === null) { - $this->fail(new \RuntimeException( - "SSH connection to {$this->ip} failed after max attempts" - )); - return; - } - - Log::info('ChangePasswordViaSshJob: chpasswd output', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'output' => trim($output), - ]); - - // Update DB - $vm = ModuleVmware::find($this->moduleVmwareId); - - if (!$vm) { - Log::error('ChangePasswordViaSshJob: ModuleVmware not found', [ - 'module_vmware_id' => $this->moduleVmwareId, - ]); - return; - } - - $vm->update([ - 'password' => SignRSA::EncryptFunc($this->newPassword), - 'plain_password' => $this->newPassword, - ]); - - $this->sendPasswordEmail($vm); - } - - // ───────────────────────────────────────────────────────────────────────── - - /** - * Run chpasswd on the remote VM via sshpass + native SSH. - * Retries up to 10 times with 30 s delay (VM may still be booting). - */ - private function runWithRetry(int $maxAttempts = 10, int $sleepSeconds = 30): ?string - { - // Remote bash command: echo 'user:pass' | chpasswd - $credential = $this->username . ':' . $this->newPassword; - $remoteCmd = 'echo ' . escapeshellarg($credential) . ' | chpasswd 2>&1'; - - $sshCmd = sprintf( - 'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 -p 22 %s@%s %s 2>&1', - escapeshellarg($this->initialPassword), - escapeshellarg($this->username), - escapeshellarg($this->ip), - escapeshellarg($remoteCmd), - ); - - for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { - $output = shell_exec($sshCmd); - - // null = shell_exec failed (command not found, etc.) - // sshpass exits non-zero on connection failure → output contains error text - if ($output !== null && !$this->isSshConnectionError($output)) { - Log::info('ChangePasswordViaSshJob: SSH connected', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'attempt' => $attempt, - ]); - return $output; - } - - Log::warning('ChangePasswordViaSshJob: SSH not ready, retrying', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'attempt' => $attempt, - 'output_preview' => substr((string) $output, 0, 200), - ]); - - if ($attempt < $maxAttempts) { - sleep($sleepSeconds); - } - } - - return null; - } - - /** - * Detect SSH connection failures (not auth errors) in sshpass output. - */ - private function isSshConnectionError(string $output): bool - { - $lower = strtolower($output); - - return str_contains($lower, 'connection refused') - || str_contains($lower, 'no route to host') - || str_contains($lower, 'connection timed out') - || str_contains($lower, 'host is down') - || str_contains($lower, 'network is unreachable'); - } - - // ───────────────────────────────────────────────────────────────────────── - - public function failed(\Throwable $e): void - { - Log::error('ChangePasswordViaSshJob: failed', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'ip' => $this->ip, - 'error' => $e->getMessage(), - ]); - } - - // ───────────────────────────────────────────────────────────────────────── - - private function sendPasswordEmail(ModuleVmware $vm): void - { - try { - $customer = $vm->customer; - - if (!$customer) { - Log::error('ChangePasswordViaSshJob: customer not found', [ - 'module_vmware_id' => $this->moduleVmwareId, - ]); - return; - } - - $setting = Setting::where('key', 'setting')->first(); - $settingValue = $setting ? json_decode((string) $setting->value) : null; - - $emailData = [ - 'company_name' => $settingValue->company ?? 'Thế Giới Số', - 'company_logo' => $settingValue->logo ?? '', - 'client_name' => trim($customer->firstname . ' ' . $customer->lastname), - 'product_name' => 'Cloud VPS', - 'product_billing' => 'N/A', - 'product_price' => 'N/A', - 'service_host' => $this->ip, - 'service_username' => $this->username, - 'service_port' => 22, - 'service_password' => $this->newPassword, - 'os_name' => ($vm->os?->osGroup?->name ?? '') . ' ' . ($vm->os?->version ?? ''), - 'service_due_exp' => $vm->next_due_date ? date('d/m/Y', (int) $vm->next_due_date) : 'N/A', - ]; - - SendInformationVirtualMachine::dispatch($emailData, $customer) - ->delay(now()->addSeconds(5)); - - Log::info('ChangePasswordViaSshJob: email dispatched', [ - 'module_vmware_id' => $this->moduleVmwareId, - 'customer_email' => $customer->email, - ]); - } catch (\Throwable $e) { - Log::error('ChangePasswordViaSshJob: email failed: ' . $e->getMessage(), [ - 'module_vmware_id' => $this->moduleVmwareId, - ]); - } - } -} diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index f694206..91ad433 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -20,7 +20,7 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; /** @@ -309,14 +309,14 @@ class UpdateVmConfigJob implements ShouldQueue $action = $powerOff ? 'stop' : 'start'; $targetState = $powerOff ? 'POWERED_OFF' : 'POWERED_ON'; - Http::withoutVerifying() + TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $service->token]) ->post("https://{$service->apiUri}/rest/vcenter/vm/{$vmwareId}/power/{$action}"); for ($i = 0; $i < 15; $i++) { sleep(2); - $state = Http::withoutVerifying() + $state = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $service->token]) ->get("https://{$service->apiUri}/rest/vcenter/vm/{$vmwareId}") ->json('value.power_state'); diff --git a/app/Models/DomainPricing.php b/app/Models/DomainPricing.php index 17407d6..4e1e816 100644 --- a/app/Models/DomainPricing.php +++ b/app/Models/DomainPricing.php @@ -43,6 +43,13 @@ class DomainPricing extends Model return is_numeric($value) ? (int) $value : (string) $value; } + public function orderPrice(int $years): int + { + $setupFee = is_numeric($this->setup_fee) ? (int) $this->setup_fee : 0; + + return $setupFee + (int) $this->first_year + (int) $this->recurring_fee * (max(1, $years) - 1); + } + public function toDomainArray(): array { return [ diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 4917147..dd63c63 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -52,6 +52,12 @@ class Invoice extends Model return $this->hasMany(Service::class, 'invoice_id'); } + /** The service this invoice upgrades, for invoice_type = 3 (upgrade). */ + public function service() + { + return $this->belongsTo(Service::class, 'service_id'); + } + public function payment() { diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php index ef8c95c..59a2a5e 100644 --- a/app/Models/InvoiceItem.php +++ b/app/Models/InvoiceItem.php @@ -27,6 +27,11 @@ class InvoiceItem extends Model return $this->belongsTo(Product::class, 'product_id'); } + public function addon() + { + return $this->belongsTo(Addon::class, 'addon_id'); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- diff --git a/app/Models/RefreshToken.php b/app/Models/RefreshToken.php index 616eae3..2465938 100644 --- a/app/Models/RefreshToken.php +++ b/app/Models/RefreshToken.php @@ -10,10 +10,12 @@ class RefreshToken extends Model 'customer_id', 'token', 'expires_at', + 'revoked_at', ]; protected $casts = [ 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', ]; public function customer() diff --git a/app/Models/ReinstallLog.php b/app/Models/ReinstallLog.php index 8a07cce..9e5f213 100644 --- a/app/Models/ReinstallLog.php +++ b/app/Models/ReinstallLog.php @@ -30,6 +30,7 @@ class ReinstallLog extends Model 'started_at' => 'datetime', 'completed_at' => 'datetime', 'password_supplied_by_customer' => 'boolean', + 'plain_password' => 'encrypted', ]; public const PRODUCT_TYPE_VPS = 4; diff --git a/app/Models/Service.php b/app/Models/Service.php index bc498e3..8b44b25 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -20,6 +20,11 @@ class Service extends Model protected $table = 'services'; + public const HOSTING_ACCOUNT_RELATIONS = [ + 'hostingAccount.server:id,name,ip_address', + 'hostingAccount.domain:id,domain_name', + ]; + protected $fillable = [ 'uuid', 'customer_id', @@ -38,6 +43,8 @@ class Service extends Model 'refunded_at', 'retry_requested_at', 'note', + 'requirements', + 'feedback', 'registrar_reference', 'kyc_url', 'rejection_reason', diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 4f8b6bc..c418c2f 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -6,7 +6,7 @@ use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; use App\Traits\HasUuid; class Transaction extends Model @@ -61,12 +61,6 @@ class Transaction extends Model }; } - /** - * `proof_image` stores either a single relative storage path, or a - * JSON-encoded array of paths (admin-created service with multiple - * proofs). Resolve both shapes into absolute, browsable URLs so the - * raw path string never leaks into the frontend as a literal href. - */ public function getProofImageUrlsAttribute(): array { if (! $this->proof_image) { @@ -78,9 +72,15 @@ class Transaction extends Model $paths = [$this->proof_image]; } - return array_values(array_map( - fn (string $path) => Storage::disk('public')->url($path), - array_filter($paths, fn ($path) => is_string($path) && $path !== '') - )); + $paths = array_values(array_filter($paths, fn ($path) => is_string($path) && $path !== '')); + + return array_map( + fn (int $index) => URL::temporarySignedRoute( + 'admin.transactions.proof-image', + now()->addDay(), + ['transaction' => $this->uuid, 'index' => $index], + ), + array_keys($paths), + ); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 047cf71..7aff6d0 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -43,6 +43,11 @@ class RouteServiceProvider extends ServiceProvider ->namespace($this->namespace) ->group(base_path('routes/api.php')); + Route::prefix('api') + ->middleware(['api']) + ->namespace($this->namespace) + ->group(base_path('routes/admin-media.php')); + Route::middleware(['api', 'json.response']) ->namespace($this->namespace) ->group(base_path('routes/client.php')); diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php index 5dc04e3..aed7c19 100644 --- a/app/Services/Api/Client/ClientAuthService.php +++ b/app/Services/Api/Client/ClientAuthService.php @@ -25,6 +25,7 @@ class ClientAuthService { // Refresh token lifetime in days const REFRESH_TOKEN_TTL_DAYS = 30; + const REFRESH_TOKEN_REUSE_GRACE_SECONDS = 30; const VERIFY_MAX_ATTEMPTS = 5; @@ -280,9 +281,27 @@ class ClientAuthService $record = RefreshToken::where('token', $hash)->first(); - if (!$record || $record->isExpired()) { - // Xoá token cũ nếu tồn tại nhưng hết hạn - $record?->delete(); + if (!$record) { + throw ValidationException::withMessages([ + 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], + ]); + } + + $isConcurrentRefresh = $record->revoked_at !== null + && $record->revoked_at->gt(now()->subSeconds(self::REFRESH_TOKEN_REUSE_GRACE_SECONDS)); + + if ($record->revoked_at !== null && !$isConcurrentRefresh) { + // Token đã rotate rồi mà còn bị dùng lại: có thể đã bị đánh cắp, + // thu hồi toàn bộ refresh token của khách này để buộc đăng nhập lại. + RefreshToken::where('customer_id', $record->customer_id)->delete(); + + throw ValidationException::withMessages([ + 'refresh_token' => ['Refresh token đã được sử dụng, mọi phiên đã bị thu hồi.'], + ]); + } + + if ($record->isExpired()) { + $record->delete(); throw ValidationException::withMessages([ 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], @@ -298,8 +317,10 @@ class ClientAuthService ]); } - // Rotation: xoá token cũ - $record->delete(); + // Rotation: đánh dấu đã dùng thay vì xoá, để phát hiện reuse nếu bị dùng lại + if (!$isConcurrentRefresh) { + RefreshToken::whereKey($record->getKey())->whereNull('revoked_at')->update(['revoked_at' => now()]); + } // Tạo access token mới $newAccessToken = JWTAuth::fromUser($customer); diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php index 9a04f1f..6f0bdc2 100644 --- a/app/Services/Api/Domain/DomainOrderService.php +++ b/app/Services/Api/Domain/DomainOrderService.php @@ -46,8 +46,7 @@ class DomainOrderService $domainName = $this->normalizeDomain($data['domain_name']); $years = max(1, (int) ($data['years'] ?? 1)); - $pricing = $this->findDomainPricing($domainName); - $price = (int) $pricing['firstYear'] * $years; + $price = DomainPricing::findByDomain($domainName)->orderPrice($years); $months = $years * 12; return DB::transaction(function () use ($customer, $product, $domainName, $price, $months, $years) { @@ -363,17 +362,6 @@ class DomainOrderService // Private helpers // ========================================================================= - /** - * Find pricing for the given domain using longest-match TLD. - * TLDs in settings are stored with a leading dot (e.g. ".com.vn"). - * - * @throws \InvalidArgumentException if no matching TLD found - */ - private function findDomainPricing(string $domain): array - { - return DomainPricing::findByDomain($domain)->toDomainArray(); - } - /** * Normalize: lowercase, trim, strip protocol and path. * "HTTPS://My-Site.COM.VN/path" → "my-site.com.vn" diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index f299a64..68c38ed 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -17,6 +17,7 @@ use App\Models\Service; use App\Models\VirtualMachines; use App\Repositories\Api\Order\OrderRepository; use App\Repositories\Api\Service\ServiceRepositoryInterface; +use App\Support\RenewalPricing; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -204,6 +205,19 @@ class OrderQueryService return ['ok' => false, 'message' => 'Không thể xác định giá gia hạn dịch vụ.', 'code' => 400]; } + $cycleMonths = OrderHelper::cycleToMonths($orderItem->billing_cycle); + $validMonths = RenewalPricing::validMonths($orderItem->billing_cycle); + + if (! in_array($months, $validMonths, true)) { + return [ + 'ok' => false, + 'message' => 'Số tháng gia hạn không hợp lệ với chu kỳ của dịch vụ này. Vui lòng chọn: ' . implode(', ', $validMonths) . ' tháng.', + 'code' => 422, + ]; + } + + $cycles = intdiv($months, $cycleMonths); + // Gia hạn luôn dùng giá sản phẩm hiện tại trong bảng giá, không dùng giá đã // chốt lúc đặt mua ban đầu — giá cũ chỉ để hiển thị trên đơn hàng/hoá đơn gốc. $currentPrice = $this->productRepo->getPricing((int) $service->product_id, $orderItem->billing_cycle); @@ -211,7 +225,9 @@ class OrderQueryService return ['ok' => false, 'message' => 'Sản phẩm hiện không còn bán, không thể gia hạn.', 'code' => 400]; } - $price = (int) $currentPrice * $months; + $renewalPrice = (int) $currentPrice * $cycles; + $addonTotal = RenewalPricing::addonTotal($service, $cycles); + $price = $renewalPrice + $addonTotal; if ($service->invoice_id && Invoice::where('id', $service->invoice_id) ->where('invoice_type', 2) @@ -238,7 +254,7 @@ class OrderQueryService $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); $periodEnd = $periodStart->copy()->addMonths($months); - $invoice = DB::transaction(function () use ($service, $orderItem, $price, $months, $periodStart, $periodEnd) { + $invoice = DB::transaction(function () use ($service, $orderItem, $price, $renewalPrice, $cycles, $months, $periodStart, $periodEnd) { $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $service->customer_id, @@ -261,10 +277,14 @@ class OrderQueryService 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), 'months' => $months, 'quantity' => 1, - 'unit_price' => $price, - 'total_price' => $price, + 'unit_price' => $renewalPrice, + 'total_price' => $renewalPrice, ]); + foreach (RenewalPricing::addonInvoiceItems($service, $cycles, $months) as $addonItem) { + \App\Models\InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]); + } + $service->update(['invoice_id' => $invoice->id]); return $invoice; diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 72f3219..b99cf79 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -5,12 +5,13 @@ namespace App\Services\Api\Order; use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Jobs\Mail\Admin\AdminOrderNotificationJob; -use App\Models\Addon; use App\Models\Order; +use App\Models\Product; use App\Repositories\Api\Invoice\InvoiceRepository; use App\Repositories\Api\Order\OrderRepository; use App\Repositories\Api\Service\ServiceRepository; use App\Services\InvoicePaymentService; +use App\Support\AddonQuantityValidator; use Exception; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -32,31 +33,31 @@ class OrderService * Validate → calculate price → persist Order + OrderItem + Invoice. * * @return array{order: Order, invoice: \App\Models\Invoice} - * @throws Exception + * @throws \InvalidArgumentException */ public function create(array $data, $customer): array { if ($this->orderRepo->hasPendingOrder($customer->id)) { - throw new Exception('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.'); + throw new \InvalidArgumentException('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.'); } $product = $this->productRepo->find($data['product_id']); if (!$product) { - throw new Exception('Sản phẩm không tồn tại.'); + throw new \InvalidArgumentException('Sản phẩm không tồn tại.'); } $basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']); if (is_null($basePrice)) { - throw new Exception('Không tìm thấy giá cho chu kỳ thanh toán này.'); + throw new \InvalidArgumentException('Không tìm thấy giá cho chu kỳ thanh toán này.'); } // Resolve addons → enriched meta with price & subtotal - $addonsMeta = $this->resolveAddons($data['addons'] ?? []); + $addonsMeta = $this->resolveAddons($data['addons'] ?? [], $product); $addonsPrice = array_sum(array_column($addonsMeta, 'subtotal')); $total = $basePrice + $addonsPrice; if ($customer->total < $total) { - throw new Exception(sprintf( + throw new \InvalidArgumentException(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($total) @@ -101,19 +102,19 @@ class OrderService /** * Debit customer balance → mark Order+Invoice paid → provision Service records. * - * @throws Exception + * @throws \InvalidArgumentException */ public function pay(Order $order, string $method): Order { if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) { - throw new Exception('Đơn hàng đã được thanh toán hoặc đang xử lý.'); + throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } return DB::transaction(function () use ($order, $method) { $customer = $order->customer; if ($customer->total < $order->total) { - throw new Exception(sprintf( + throw new \InvalidArgumentException(sprintf( 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($order->total) @@ -126,7 +127,7 @@ class OrderService } if (! $order->invoice) { - throw new Exception('Không tìm thấy hóa đơn của đơn hàng.'); + throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.'); } $this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method); @@ -175,25 +176,13 @@ class OrderService * * Input: [['addon_id' => 1, 'qty' => 2], ...] * Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...] + * + * @throws \Illuminate\Validation\ValidationException */ - private function resolveAddons(array $raw): array + private function resolveAddons(array $raw, Product $product): array { - $resolved = []; - foreach ($raw as $entry) { - $addon = Addon::find($entry['addon_id'] ?? null); - if (!$addon) { - continue; - } - $qty = max(1, (int) ($entry['qty'] ?? 1)); - $resolved[] = [ - 'addon_id' => $addon->id, - 'code' => strtolower((string) $addon->code), - 'name' => $addon->name, - 'qty' => $qty, - 'price' => $addon->price, - 'subtotal' => $addon->price * $qty, - ]; - } - return $resolved; + $activeAddons = $product->addons()->where('status', true)->get()->keyBy('id'); + + return AddonQuantityValidator::resolve($raw, $activeAddons); } } diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index f69d2c9..7f4748c 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -76,7 +76,13 @@ class CustomerServiceProvisioningService { $services = $this->serviceRepository->filter( ['customer_uuid' => $uuid], - ['product', 'orderItem', 'invoice', 'vmware.os.osGroup'], + [ + 'product', + 'orderItem', + 'invoice', + 'vmware.os.osGroup', + ...Service::HOSTING_ACCOUNT_RELATIONS, + ], ); ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware')); diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php index bf80b7a..566712b 100644 --- a/app/Services/InvoicePaymentService.php +++ b/app/Services/InvoicePaymentService.php @@ -9,6 +9,8 @@ use App\Helpers\OrderHelper; use App\Jobs\Hosting\ProvisionHostingAccountJob; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob; +use App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob; +use App\Http\Services\vCenter as ServiceVCenter; use App\Models\Addon; use App\Models\Customer; use App\Models\Invoice; @@ -19,6 +21,7 @@ use App\Models\ServiceAddon; use App\Models\Transaction; use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; class InvoicePaymentService @@ -125,6 +128,8 @@ class InvoicePaymentService if ((int) $invoice->invoice_type === 2) { $this->activateRenewal($invoice); + } elseif ((int) $invoice->invoice_type === 3) { + $this->activateUpgrade($invoice); } elseif ($invoice->order) { $this->activateOrder($invoice); } @@ -197,6 +202,8 @@ class InvoicePaymentService if ((int) $invoice->invoice_type === 2) { $this->activateRenewal($invoice); + } elseif ((int) $invoice->invoice_type === 3) { + $this->activateUpgrade($invoice); } elseif ($invoice->order) { $this->activateOrder($invoice); } @@ -385,6 +392,107 @@ class InvoicePaymentService } } + /** + * Merges the purchased addons into order_items.addons_meta (so RenewalPricing charges + * them on the next renewal) and applies the new absolute CPU/RAM/disk totals to the VM. + */ + private function activateUpgrade(Invoice $invoice): void + { + $service = Service::query() + ->with(['orderItem.product', 'vmware']) + ->find($invoice->service_id); + + $orderItem = $service?->orderItem; + if (! $service || ! $orderItem) { + return; + } + + $upgradeItems = $invoice->invoiceItems()->where('type', 'upgrade')->get(); + if ($upgradeItems->isEmpty()) { + return; + } + + $meta = collect(is_array($orderItem->addons_meta) ? $orderItem->addons_meta : []); + $purchasedCodes = []; + + foreach ($upgradeItems as $item) { + $addon = Addon::find($item->addon_id); + if (! $addon) { + continue; + } + + $code = strtolower((string) $addon->code); + $purchasedCodes[] = $code; + + $qty = (int) $item->quantity; + $index = $meta->search(fn ($entry) => (int) ($entry['addon_id'] ?? 0) === $addon->id); + $currentQty = $index === false ? 0 : (int) ($meta->get($index)['qty'] ?? 0); + $newQty = $currentQty + $qty; + + $entry = [ + 'addon_id' => $addon->id, + 'code' => $code, + 'name' => $addon->name, + 'qty' => $newQty, + 'price' => (float) $addon->price, + 'subtotal' => $newQty * (float) $addon->price, + ]; + + if ($index === false) { + $meta->push($entry); + } else { + $meta->put($index, $entry); + } + } + + $orderItem->update(['addons_meta' => $meta->values()->all()]); + + $moduleVmware = $service->vmware; + if (! $moduleVmware) { + return; + } + + $addonQtyByCode = $meta->keyBy(fn ($entry) => $entry['code'] ?? ''); + $product = $orderItem->product; + + if (array_intersect(['cpu', 'ram'], $purchasedCodes)) { + $totalCpu = (int) ($product?->cpu ?? 0) + (int) ($addonQtyByCode->get('cpu')['qty'] ?? 0); + $totalRam = (int) ($product?->ram ?? 0) + (int) ($addonQtyByCode->get('ram')['qty'] ?? 0); + + UpdateVmConfigJob::dispatch( + $moduleVmware->id, + (string) $moduleVmware->vmware_id, + '', + $totalCpu, + $totalRam, + )->afterCommit(); + } + + if (in_array('disk', $purchasedCodes, true)) { + $totalDisk = (int) ($product?->disk ?? 0) + (int) ($addonQtyByCode->get('disk')['qty'] ?? 0); + $vcenter = $moduleVmware->vCenter ?? $moduleVmware->ipAddress?->host?->datacenter?->vCenter; + + if ($vcenter) { + DB::afterCommit(function () use ($vcenter, $moduleVmware, $totalDisk, $invoice) { + try { + $serviceVCenter = new ServiceVCenter(); + if (! $serviceVCenter->loginServer($vcenter)) { + throw new \RuntimeException('vCenter login failed'); + } + $serviceVCenter->UpgradeDiskVirtualMachine($moduleVmware, $totalDisk); + } catch (\Throwable $e) { + Log::error('activateUpgrade: disk upgrade failed', [ + 'invoice_id' => $invoice->id, + 'module_vmware_id' => $moduleVmware->id, + 'total_disk' => $totalDisk, + 'error' => $e->getMessage(), + ]); + } + }); + } + } + } + private function attachServiceAddons(Service $service, OrderItem $item): void { if ($service->addons()->exists()) { diff --git a/app/Services/InvoiceReminderService.php b/app/Services/InvoiceReminderService.php index 9a8e74a..b555ac6 100644 --- a/app/Services/InvoiceReminderService.php +++ b/app/Services/InvoiceReminderService.php @@ -37,7 +37,7 @@ class InvoiceReminderService } $now = $now ?? now(); - $daysFromDue = $invoice->due_date->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false); + $daysFromDue = (int) $invoice->due_date->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false); $offsets = (array) config('billing.reminder_offsets', []); if ($offsets === []) { @@ -95,7 +95,7 @@ class InvoiceReminderService { $now = $now ?? now(); $dueDate = $invoice->due_date; - $daysLate = $dueDate ? $dueDate->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false) : 0; + $daysLate = $dueDate ? (int) $dueDate->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false) : 0; $outstanding = max(0, (int) $invoice->total - (int) ($invoice->amount_paid ?? 0)); return [ diff --git a/app/Services/Plesk/PleskService.php b/app/Services/Plesk/PleskService.php index 18cd0e0..b65b366 100644 --- a/app/Services/Plesk/PleskService.php +++ b/app/Services/Plesk/PleskService.php @@ -6,7 +6,7 @@ use App\Facades\BaseHelperFacade as BaseHelper; use App\Models\DnsServer; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Crypt; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; class PleskService { @@ -17,7 +17,7 @@ class PleskService public function client(DnsServer $server, int $timeout = 30): PendingRequest { - return Http::withoutVerifying() + return TlsHttpClientFactory::plesk() ->withBasicAuth($server->username, Crypt::decryptString($server->password)) ->timeout($timeout); } diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index cccadb2..5a32e42 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -6,7 +6,7 @@ use App\Facades\BaseHelperFacade as BaseHelper; use App\Models\DnsServer; use App\Repositories\DnsManager\Server\DnsServerRepositoryInterface; use App\Services\Plesk\PleskDomainService; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Log; @@ -77,7 +77,7 @@ class ServerService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) ->timeout(10) ->post( @@ -103,7 +103,7 @@ class ServerService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) ->timeout(15) ->get( @@ -127,7 +127,7 @@ class ServerService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) ->timeout(10) ->put( @@ -153,7 +153,7 @@ class ServerService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) ->timeout(30) ->delete( @@ -178,7 +178,7 @@ class ServerService } try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::plesk() ->withBasicAuth($data['username'], $data['password']) ->timeout(10) ->get(BaseHelper::url($data['ip_address']) . $this->entry_points['server']); diff --git a/app/Services/VirtualMachine/Rebuild/VmRebuildCloneService.php b/app/Services/VirtualMachine/Rebuild/VmRebuildCloneService.php index c41cd11..456e668 100644 --- a/app/Services/VirtualMachine/Rebuild/VmRebuildCloneService.php +++ b/app/Services/VirtualMachine/Rebuild/VmRebuildCloneService.php @@ -8,7 +8,7 @@ use App\DTOs\VirtualMachine\Rebuild\RebuildContext; use App\Http\Services\vCenter as VCenterService; use App\Models\ModuleDatastore; use App\Models\vCenter as VCenterModel; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; use RuntimeException; @@ -74,7 +74,7 @@ final class VmRebuildCloneService 'template' => $ctx->template, ]); - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->timeout(self::CLONE_TIMEOUT) ->connectTimeout(30) ->withHeaders([ @@ -121,7 +121,7 @@ final class VmRebuildCloneService throw new RuntimeException("Rebuild powerOn: cannot login to vCenter #{$ctx->vcenterId}"); } - $powerResp = Http::withoutVerifying() + $powerResp = TlsHttpClientFactory::vCenter() ->timeout(self::POWER_ON_TIMEOUT) ->withHeaders(['vmware-api-session-id' => $this->vCenter->token]) ->post("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->newVmId}/power/start"); @@ -138,7 +138,7 @@ final class VmRebuildCloneService ]); for ($i = 1; $i <= self::TOOLS_MAX_ATTEMPTS; $i++) { - $info = Http::withoutVerifying() + $info = TlsHttpClientFactory::vCenter() ->timeout(10) ->withHeaders(['vmware-api-session-id' => $this->vCenter->token]) ->get("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->newVmId}/guest/identity") @@ -185,7 +185,7 @@ final class VmRebuildCloneService } try { - Http::withoutVerifying() + TlsHttpClientFactory::vCenter() ->timeout(30) ->withHeaders(['vmware-api-session-id' => $this->vCenter->token]) ->post("https://{$this->vCenter->apiUri}/rest/vcenter/vm/{$ctx->oldVmId}/power/stop"); diff --git a/app/Services/VirtualMachine/VmIpReservationService.php b/app/Services/VirtualMachine/VmIpReservationService.php index 29b011f..c14795f 100644 --- a/app/Services/VirtualMachine/VmIpReservationService.php +++ b/app/Services/VirtualMachine/VmIpReservationService.php @@ -74,6 +74,53 @@ class VmIpReservationService }); } + /** + * Reserve a specific, still-free IP within a network (customer-picked change-IP flow). + * + * @return array{ip_pool_id:int, gateway:string}|null + */ + public function reserveInNetwork(int $networkId, string $ipAddress, int $serviceId): ?array + { + return DB::transaction(function () use ($networkId, $ipAddress, $serviceId) { + $ipPool = IpPool::where('module_network_id', $networkId) + ->where('ip_address', $ipAddress) + ->where('status', 'free') + ->lockForUpdate() + ->first(); + + if (!$ipPool) { + return null; + } + + $ipPool->update(['status' => 'assigned', 'service_id' => $serviceId]); + + return [ + 'ip_pool_id' => $ipPool->id, + 'gateway' => (string) ($ipPool->gateway ?? ''), + ]; + }); + } + + public function freeIpsInNetwork(int $networkId) + { + return IpPool::where('module_network_id', $networkId) + ->where('status', 'free') + ->orderBy('ip_address') + ->get(['id', 'ip_address', 'gateway']); + } + + public function releaseByPoolId(int $ipPoolId): void + { + try { + IpPool::where('id', $ipPoolId)->update(['status' => 'free', 'service_id' => null]); + } catch (\Throwable $e) { + Log::warning('VmIpReservationService: releaseByPoolId failed', [ + 'ip_pool_id' => $ipPoolId, + 'error' => $e->getMessage(), + ]); + } + } + public function release(ModuleIpAddress $reservation, ?int $ipPoolId): void { try { diff --git a/app/Services/VirtualMachine/VmPowerService.php b/app/Services/VirtualMachine/VmPowerService.php index f350d29..c1b0273 100644 --- a/app/Services/VirtualMachine/VmPowerService.php +++ b/app/Services/VirtualMachine/VmPowerService.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Services\VirtualMachine; use App\Http\Services\vCenter; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; use Throwable; @@ -18,7 +18,7 @@ class VmPowerService return 'Login_Again'; } - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $vCenter->token]) ->post("https://{$vCenter->apiUri}/rest/vcenter/vm/{$vmId}/power/start"); diff --git a/app/Services/VmProvisionService.php b/app/Services/VmProvisionService.php index 5c8a47e..eec3847 100644 --- a/app/Services/VmProvisionService.php +++ b/app/Services/VmProvisionService.php @@ -7,7 +7,7 @@ namespace App\Services; use App\Http\Facade\SignRSA; use App\Http\Services\vCenter as VCenterService; use App\Models\ModuleVmware; -use Illuminate\Support\Facades\Http; +use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; final class VmProvisionService @@ -38,7 +38,7 @@ final class VmProvisionService public function getVmGuestInfo(VCenterService $service, string $vmId): array { try { - $response = Http::withoutVerifying() + $response = TlsHttpClientFactory::vCenter() ->withHeaders(['vmware-api-session-id' => $service->token]) ->timeout(10) ->get("https://{$service->apiUri}/rest/vcenter/vm/{$vmId}/guest/identity"); diff --git a/app/Services/VsphereHostSshService.php b/app/Services/VsphereHostSshService.php index 089764e..00b0b46 100644 --- a/app/Services/VsphereHostSshService.php +++ b/app/Services/VsphereHostSshService.php @@ -6,6 +6,8 @@ namespace App\Services; use App\Http\Facade\SignRSA; use App\Models\InfraManagement; +use App\Support\PowerShellEscaper; +use App\Support\SshHostKeyVerifier; use Illuminate\Support\Facades\Log; use phpseclib3\Net\SSH2; @@ -207,6 +209,8 @@ final class VsphereHostSshService $client = new SSH2($config['host'], $config['port'], self::CONNECT_TIMEOUT); + SshHostKeyVerifier::verify($client, $config['host']); + if (!$client->login($config['username'], $config['password'])) { throw new \RuntimeException( "SSH login failed: {$config['host']}:{$config['port']}" @@ -364,23 +368,14 @@ final class VsphereHostSshService */ private function windowsPowerShellCommand(string $workingDirectory, string $script, array $arguments): string { - return sprintf( - 'powershell -ExecutionPolicy Bypass -NonInteractive -File "%s\\%s" %s', - $workingDirectory, - $script, - implode(' ', array_map( - fn (string $argument): string => $this->warg($argument), - $arguments, - )), - ); - } + $scriptPath = $workingDirectory . '\\' . $script; + $psScript = '& ' . PowerShellEscaper::singleQuote($scriptPath) . ' ' . implode(' ', array_map( + fn (string $argument): string => PowerShellEscaper::singleQuote($argument), + $arguments, + )); - /** - * Windows cmd.exe safe argument: wrap in double quotes, escape inner double quotes. - */ - private function warg(string $value): string - { - return '"' . str_replace('"', '\\"', $value) . '"'; + return 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ' + . PowerShellEscaper::encodedCommand($psScript); } /** diff --git a/app/Support/AddonQuantityValidator.php b/app/Support/AddonQuantityValidator.php new file mode 100644 index 0000000..92eabbc --- /dev/null +++ b/app/Support/AddonQuantityValidator.php @@ -0,0 +1,72 @@ + $raw + * @param Collection $activeAddons Keyed by addon id. + * @param array $existingQtyByAddonId Already-owned qty per addon id, for capping total against max. + * @return list + * + * @throws ValidationException + */ + public static function resolve(array $raw, Collection $activeAddons, array $existingQtyByAddonId = []): array + { + $resolved = []; + + foreach ($raw as $entry) { + $addonId = $entry['addon_id'] ?? null; + if (!$addonId) { + continue; + } + + $addon = $activeAddons->get($addonId); + if (!$addon) { + throw ValidationException::withMessages([ + 'addons' => ['Addon không tồn tại hoặc không áp dụng cho sản phẩm này.'], + ]); + } + + $qty = (int) ($entry['qty'] ?? 1); + + if ($qty < $addon->min) { + throw ValidationException::withMessages([ + 'addons' => ["Số lượng addon {$addon->name} phải lớn hơn hoặc bằng {$addon->min}."], + ]); + } + + $totalQty = ($existingQtyByAddonId[$addonId] ?? 0) + $qty; + + if ($addon->max > 0 && $totalQty > $addon->max) { + throw ValidationException::withMessages([ + 'addons' => ["Số lượng addon {$addon->name} không được vượt quá {$addon->max}."], + ]); + } + + if ($addon->step > 0 && $qty % $addon->step !== 0) { + throw ValidationException::withMessages([ + 'addons' => ["Số lượng addon {$addon->name} phải là bội số của {$addon->step}."], + ]); + } + + $resolved[] = [ + 'addon_id' => $addon->id, + 'code' => strtolower((string) $addon->code), + 'name' => $addon->name, + 'qty' => $qty, + 'price' => $addon->price, + 'subtotal' => $addon->price * $qty, + ]; + } + + return $resolved; + } +} diff --git a/app/Support/PowerCliCertificatePolicy.php b/app/Support/PowerCliCertificatePolicy.php new file mode 100644 index 0000000..ef4d620 --- /dev/null +++ b/app/Support/PowerCliCertificatePolicy.php @@ -0,0 +1,15 @@ + */ + public static function validMonths(string $billingCycle): array + { + $cycleMonths = OrderHelper::cycleToMonths($billingCycle); + + if ($cycleMonths > 24) { + return [$cycleMonths]; + } + + $maxMultiple = intdiv(24, $cycleMonths); + + return array_map(static fn (int $n): int => $n * $cycleMonths, range(1, $maxMultiple)); + } + + public static function addonTotal(Service $service, int $cycles): int + { + return (int) array_sum(array_column(self::addonInvoiceItems($service, $cycles, 0), 'total_price')); + } + + /** @return list> */ + public static function addonInvoiceItems(Service $service, int $cycles, int $months): array + { + $meta = array_filter( + (array) ($service->orderItem?->addons_meta ?? []), + static fn ($entry): bool => is_array($entry) && (int) ($entry['qty'] ?? 0) > 0, + ); + + if ($meta === []) { + return []; + } + + $currentPrices = Addon::whereIn('id', array_column($meta, 'addon_id'))->pluck('price', 'id'); + + $items = []; + foreach ($meta as $entry) { + $qty = (int) $entry['qty']; + $unitPrice = (int) ($currentPrices[$entry['addon_id'] ?? null] ?? $entry['price'] ?? 0); + $totalPrice = $unitPrice * $qty * $cycles; + + if ($totalPrice <= 0) { + continue; + } + + $items[] = [ + 'product_id' => $service->product_id, + 'type' => 'addon', + 'description' => 'Addon: ' . ($entry['name'] ?? strtoupper((string) ($entry['code'] ?? ''))) . ' x' . $qty, + 'months' => $months, + 'quantity' => $qty, + 'unit_price' => $unitPrice * $cycles, + 'total_price' => $totalPrice, + ]; + } + + return $items; + } +} diff --git a/app/Support/SshHostKeyVerifier.php b/app/Support/SshHostKeyVerifier.php new file mode 100644 index 0000000..bc4f843 --- /dev/null +++ b/app/Support/SshHostKeyVerifier.php @@ -0,0 +1,45 @@ +getServerPublicHostKey(); + $serverKeyParts = preg_split('/\s+/', trim($serverKey)); + $serverKeyValue = $serverKeyParts[1] ?? $serverKey; + + $lines = file($knownHostsPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + foreach ($lines as $line) { + $parts = preg_split('/\s+/', trim($line)); + if (count($parts) < 3 || $parts[0] !== $host) { + continue; + } + if (hash_equals($parts[2], $serverKeyValue)) { + return; + } + } + + throw new RuntimeException("SSH host key verification failed for {$host}: no matching known_hosts entry"); + } +} diff --git a/app/Support/TlsHttpClientFactory.php b/app/Support/TlsHttpClientFactory.php new file mode 100644 index 0000000..4dcef9e --- /dev/null +++ b/app/Support/TlsHttpClientFactory.php @@ -0,0 +1,42 @@ + self::verifyOption('vmware')]); + } + + public static function plesk(): PendingRequest + { + return Http::withOptions(['verify' => self::verifyOption('services.plesk')]); + } + + /** @return array */ + public static function vCenterCurlOptions(): array + { + $verify = self::verifyOption('vmware'); + + return array_filter([ + CURLOPT_SSL_VERIFYPEER => $verify !== false, + CURLOPT_SSL_VERIFYHOST => $verify === false ? 0 : 2, + CURLOPT_CAINFO => is_string($verify) ? $verify : null, + ], fn ($value) => $value !== null); + } + + private static function verifyOption(string $configKey): bool|string + { + if (!config("{$configKey}.tls_verify", true)) { + return false; + } + + return config("{$configKey}.ca_bundle") ?: true; + } +} diff --git a/composer.json b/composer.json index 4f12cd4..a913c08 100644 --- a/composer.json +++ b/composer.json @@ -8,32 +8,32 @@ ], "license": "MIT", "require": { - "php": "^8.1", + "php": "^8.2", "ext-openssl": "*", "barryvdh/laravel-dompdf": "^3.1", - "darkaonline/l5-swagger": "^8.6", + "darkaonline/l5-swagger": "^9.0", "guzzlehttp/guzzle": "^7.2", - "kalnoy/nestedset": "^6.0", - "laravel/framework": "^10.0", - "laravel/horizon": "^5.4.0", - "laravel/tinker": "^2.7", + "kalnoy/nestedset": "^6.0.5", + "laravel/framework": "^12.61.1", + "laravel/horizon": "^5.30", + "laravel/tinker": "^2.10", "maatwebsite/excel": "^3.1", - "nwidart/laravel-modules": "^10.0", - "opcodesio/log-viewer": "^3.18", + "nwidart/laravel-modules": "^12.0", + "opcodesio/log-viewer": "^3.15", "phpoffice/phpword": "^1.4", - "phpseclib/phpseclib": "*", + "phpseclib/phpseclib": "^3.0", "plesk/ext-laravel-integration": "^7.0", "predis/predis": "^2.2", "pusher/pusher-php-server": "^7.2", - "spatie/laravel-permission": "^6.3", - "tymon/jwt-auth": "^2.0" + "spatie/laravel-permission": "^6.16", + "tymon/jwt-auth": "^2.2" }, "require-dev": { - "fakerphp/faker": "^1.9.1", - "laravel/sail": "^1.0.1", - "mockery/mockery": "^1.4.4", - "nunomaduro/collision": "^7.12", - "phpunit/phpunit": "^9.5.10", + "fakerphp/faker": "^1.23", + "laravel/sail": "^1.41", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.6", + "phpunit/phpunit": "^11.5", "spatie/laravel-ignition": "^2.9" }, "autoload": { @@ -77,7 +77,10 @@ "preferred-install": "dist", "sort-packages": true, "platform": { - "php": "8.1.0" + "php": "8.2.0" + }, + "allow-plugins": { + "wikimedia/composer-merge-plugin": true } }, "minimum-stability": "dev", diff --git a/composer.lock b/composer.lock index 0b1f45a..173d2de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "862dcec8a5b4f25a9fa0f58eb3dd1556", + "content-hash": "90f4ee6803501e1881ce240d8a6bd3bd", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -85,25 +85,25 @@ }, { "name": "brick/math", - "version": "0.12.3", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", - "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -133,7 +133,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.3" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -141,34 +141,39 @@ "type": "github" } ], - "time": "2025-02-28T13:11:00+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", - "version": "2.1.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb" + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", - "reference": "99f76ffa36cce3b70a4a6abce41dba15ca2e84cb", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", "shasum": "" }, "require": { - "php": "^7.4 || ^8.0" + "php": "^8.1" }, "conflict": { - "doctrine/dbal": "<3.7.0 || >=4.0.0" + "doctrine/dbal": "<4.0.0 || >=5.0.0" }, "require-dev": { - "doctrine/dbal": "^3.7.0", + "doctrine/dbal": "^4.0.0", "nesbot/carbon": "^2.71.0 || ^3.0.0", "phpunit/phpunit": "^10.3" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { "psr-4": { "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" @@ -194,7 +199,7 @@ ], "support": { "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", - "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/2.1.0" + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" }, "funding": [ { @@ -210,7 +215,7 @@ "type": "tidelift" } ], - "time": "2023-12-11T17:09:12+00:00" + "time": "2026-09-06T14:11:38+00:00" }, { "name": "composer/pcre", @@ -290,16 +295,16 @@ }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -341,7 +346,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -351,7 +356,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -363,36 +368,37 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "darkaonline/l5-swagger", - "version": "8.6.5", + "version": "9.0.1", "source": { "type": "git", "url": "https://github.com/DarkaOnLine/L5-Swagger.git", - "reference": "4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85" + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DarkaOnLine/L5-Swagger/zipball/4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85", - "reference": "4cf2b3faae9e9cffd05e4eb6e066741bf56f0a85", + "url": "https://api.github.com/repos/DarkaOnLine/L5-Swagger/zipball/2c26427f8c41db8e72232415e7287313e6b6a2e2", + "reference": "2c26427f8c41db8e72232415e7287313e6b6a2e2", "shasum": "" }, "require": { "doctrine/annotations": "^1.0 || ^2.0", "ext-json": "*", - "laravel/framework": "^11.0 || ^10.0 || ^9.0 || >=8.40.0 || ^7.0", - "php": "^7.2 || ^8.0", - "swagger-api/swagger-ui": "^3.0 || >=4.1.3", + "laravel/framework": "^12.0 || ^11.0", + "php": "^8.2", + "swagger-api/swagger-ui": ">=5.18.3", "symfony/yaml": "^5.0 || ^6.0 || ^7.0", - "zircote/swagger-php": "^3.2.0 || ^4.0.0" + "zircote/swagger-php": "^5.0.0" }, "require-dev": { "mockery/mockery": "1.*", - "orchestra/testbench": "^9.0 || ^8.0 || 7.* || ^6.15 || 5.*", + "orchestra/testbench": "^10.0 || ^9.0 || ^8.0 || 7.* || ^6.15 || 5.*", "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^11.0 || ^10.0 || ^9.5" + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { @@ -435,7 +441,7 @@ ], "support": { "issues": "https://github.com/DarkaOnLine/L5-Swagger/issues", - "source": "https://github.com/DarkaOnLine/L5-Swagger/tree/8.6.5" + "source": "https://github.com/DarkaOnLine/L5-Swagger/tree/9.0.1" }, "funding": [ { @@ -443,7 +449,7 @@ "type": "github" } ], - "time": "2025-02-06T14:54:32+00:00" + "time": "2025-02-28T06:25:02+00:00" }, { "name": "dflydev/dot-access-data", @@ -689,27 +695,25 @@ }, { "name": "doctrine/lexer", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", - "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c", + "reference": "e96fe45e92a54233726014a7cc7340abf29bb14c", "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" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.5.58 || ^12.5.4" }, "type": "library", "autoload": { @@ -746,7 +750,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/3.0.1" + "source": "https://github.com/doctrine/lexer/tree/3.0.2" }, "funding": [ { @@ -762,20 +766,20 @@ "type": "tidelift" } ], - "time": "2024-02-05T11:56:58+00:00" + "time": "2026-06-14T20:44:06+00:00" }, { "name": "dompdf/dompdf", - "version": "v3.1.5", + "version": "v3.1.6", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", - "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", "shasum": "" }, "require": { @@ -824,9 +828,9 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" }, - "time": "2026-03-03T13:54:37+00:00" + "time": "2026-07-20T12:29:38+00:00" }, { "name": "dompdf/php-font-lib", @@ -921,20 +925,20 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.5.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "1b2de7f4a468165dca07b142240733a1973e766d" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/1b2de7f4a468165dca07b142240733a1973e766d", - "reference": "1b2de7f4a468165dca07b142240733a1973e766d", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" @@ -973,7 +977,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.5.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -981,7 +985,7 @@ "type": "github" } ], - "time": "2025-10-31T18:36:32+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -1052,16 +1056,16 @@ }, { "name": "ezyang/htmlpurifier", - "version": "v4.19.0", + "version": "v4.19.1", "source": { "type": "git", "url": "https://github.com/ezyang/htmlpurifier.git", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", - "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/5e0539132d934f936fbae2c88b55d58f9438623f", + "reference": "5e0539132d934f936fbae2c88b55d58f9438623f", "shasum": "" }, "require": { @@ -1107,9 +1111,9 @@ ], "support": { "issues": "https://github.com/ezyang/htmlpurifier/issues", - "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.1" }, - "time": "2025-10-17T16:34:55+00:00" + "time": "2026-09-15T15:42:51+00:00" }, { "name": "fruitcake/php-cors", @@ -1184,24 +1188,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.4", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", - "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5" + "phpoption/phpoption": "^1.10" }, "require-dev": { - "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" }, "type": "library", "autoload": { @@ -1230,7 +1234,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" }, "funding": [ { @@ -1242,26 +1246,26 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:43:20+00:00" + "time": "2026-08-24T09:06:52+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.13.2", + "version": "7.15.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd" + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bcd989ad36c92d42a3715379af91f2defee5b8dd", - "reference": "bcd989ad36c92d42a3715379af91f2defee5b8dd", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.12.3", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -1274,7 +1278,7 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.3", - "guzzlehttp/test-server": "^0.6", + "guzzlehttp/test-server": "^0.7", "php-http/message-factory": "^1.1", "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" @@ -1354,7 +1358,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.13.2" + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" }, "funding": [ { @@ -1370,20 +1374,20 @@ "type": "tidelift" } ], - "time": "2026-07-05T19:00:11+00:00" + "time": "2026-08-24T09:21:06+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.0", + "version": "2.5.3", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", - "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", "shasum": "" }, "require": { @@ -1438,7 +1442,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.0" + "source": "https://github.com/guzzle/promises/tree/2.5.3" }, "funding": [ { @@ -1454,20 +1458,20 @@ "type": "tidelift" } ], - "time": "2026-06-02T12:23:43+00:00" + "time": "2026-08-24T09:11:28+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.12.3", + "version": "2.13.1", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + "reference": "95e7828100de18b4e269fb1703be530082d5166d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", - "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", "shasum": "" }, "require": { @@ -1557,7 +1561,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.12.3" + "source": "https://github.com/guzzle/psr7/tree/2.13.1" }, "funding": [ { @@ -1573,20 +1577,20 @@ "type": "tidelift" } ], - "time": "2026-06-23T15:21:08+00:00" + "time": "2026-08-24T09:13:11+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.8", + "version": "v1.0.11", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd" + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/9c19128923b05a5d7355e5d2318d7808b7e33bbd", - "reference": "9c19128923b05a5d7355e5d2318d7808b7e33bbd", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/d0058dccf4299d70c3d9da3378b8908b32780368", + "reference": "d0058dccf4299d70c3d9da3378b8908b32780368", "shasum": "" }, "require": { @@ -1643,7 +1647,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.8" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.11" }, "funding": [ { @@ -1659,30 +1663,30 @@ "type": "tidelift" } ], - "time": "2026-06-23T13:02:23+00:00" + "time": "2026-08-24T09:15:32+00:00" }, { "name": "kalnoy/nestedset", - "version": "v6.0.7", + "version": "v6.0.6", "source": { "type": "git", "url": "https://github.com/lazychaser/laravel-nestedset.git", - "reference": "e73b872bc9c5419512e5b69829c0ba09d2dd6b2b" + "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lazychaser/laravel-nestedset/zipball/e73b872bc9c5419512e5b69829c0ba09d2dd6b2b", - "reference": "e73b872bc9c5419512e5b69829c0ba09d2dd6b2b", + "url": "https://api.github.com/repos/lazychaser/laravel-nestedset/zipball/3cfc56a9759fb592bc903056166bfc0867f9e679", + "reference": "3cfc56a9759fb592bc903056166bfc0867f9e679", "shasum": "" }, "require": { - "illuminate/database": ">=7.0,<=12.0", - "illuminate/events": ">=7.0,<=12.0", - "illuminate/support": ">=7.0,<=12.0", + "illuminate/database": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/events": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", "php": "^8.0" }, "require-dev": { - "phpunit/phpunit": ">=7.0" + "phpunit/phpunit": "7.*|8.*|9.*|^10.5" }, "type": "library", "extra": { @@ -1720,29 +1724,29 @@ ], "support": { "issues": "https://github.com/lazychaser/laravel-nestedset/issues", - "source": "https://github.com/lazychaser/laravel-nestedset/tree/v6.0.7" + "source": "https://github.com/lazychaser/laravel-nestedset/tree/v6.0.6" }, - "time": "2026-04-11T13:39:46+00:00" + "time": "2025-04-22T19:38:02+00:00" }, { "name": "laravel/framework", - "version": "10.x-dev", + "version": "v12.69.2", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "3ff39b7a9b83e633383ec9b019827ed54b6d38bc" + "reference": "17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/3ff39b7a9b83e633383ec9b019827ed54b6d38bc", - "reference": "3ff39b7a9b83e633383ec9b019827ed54b6d38bc", + "url": "https://api.github.com/repos/laravel/framework/zipball/17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5", + "reference": "17d034ef1e209b63a1d3a89c90ce5e89a4eb3fc5", "shasum": "" }, "require": { - "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", - "dragonmantank/cron-expression": "^3.3.2", + "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^3.2.1|^4.0", "ext-ctype": "*", "ext-filter": "*", @@ -1751,44 +1755,47 @@ "ext-openssl": "*", "ext-session": "*", "ext-tokenizer": "*", - "fruitcake/php-cors": "^1.2", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", "guzzlehttp/uri-template": "^1.0", - "laravel/prompts": "^0.1.9", - "laravel/serializable-closure": "^1.3", - "league/commonmark": "^2.2.1", - "league/flysystem": "^3.8.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "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": "^2.67", - "nunomaduro/termwind": "^1.13", - "php": "^8.1", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", "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": "^6.2", - "symfony/error-handler": "^6.2", - "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.4", - "symfony/http-kernel": "^6.2", - "symfony/mailer": "^6.2", - "symfony/mime": "^6.2", - "symfony/process": "^6.2", - "symfony/routing": "^6.2", - "symfony/uid": "^6.2", - "symfony/var-dumper": "^6.2", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", - "vlucas/phpdotenv": "^5.4.1", - "voku/portable-ascii": "^2.0" + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" }, "conflict": { - "carbonphp/carbon-doctrine-types": ">=3.0", - "doctrine/dbal": ">=4.0", - "mockery/mockery": "1.6.8", - "phpunit/phpunit": ">=11.0.0", "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": { @@ -1797,6 +1804,7 @@ "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", @@ -1809,6 +1817,7 @@ "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", @@ -1818,42 +1827,47 @@ "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" + "illuminate/view": "self.version", + "spatie/once": "*" }, "require-dev": { "ably/ably-php": "^1.0", - "aws/aws-sdk-php": "^3.235.5", - "doctrine/dbal": "^3.5.1", + "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", - "fakerphp/faker": "^1.21", - "guzzlehttp/guzzle": "^7.5", - "league/flysystem-aws-s3-v3": "^3.0", - "league/flysystem-ftp": "^3.0", - "league/flysystem-path-prefixing": "^3.3", - "league/flysystem-read-only": "^3.3", - "league/flysystem-sftp-v3": "^3.0", - "mockery/mockery": "^1.5.1", - "nyholm/psr7": "^1.2", - "orchestra/testbench-core": "^8.23.4", - "pda/pheanstalk": "^4.0", - "phpstan/phpstan": "~1.11.11", - "phpunit/phpunit": "^10.0.7", - "predis/predis": "^2.0.2", - "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4", - "symfony/psr-http-message-bridge": "^2.0" + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "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": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.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.235.5).", - "brianium/paratest": "Required to run tests in parallel (^6.0).", - "doctrine/dbal": "Required to rename columns and drop SQLite columns (^3.5.1).", + "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.", @@ -1862,34 +1876,34 @@ "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).", - "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "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).", - "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.5).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", - "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", - "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.3).", - "league/flysystem-read-only": "Required to use read-only disks (^3.3)", - "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", - "mockery/mockery": "Required to use mocking (^1.5.1).", - "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8|^10.0.7).", - "predis/predis": "Required to use the predis connector (^2.0.2).", + "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 (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "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).", - "symfony/cache": "Required to PSR-6 cache bridge (^6.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^6.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "10.x-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -1899,6 +1913,9 @@ "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": { @@ -1906,7 +1923,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1930,20 +1948,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-02-15T14:12:07+00:00" + "time": "2026-09-08T14:29:09+00:00" }, { "name": "laravel/horizon", - "version": "v5.47.2", + "version": "v5.50.0", "source": { "type": "git", "url": "https://github.com/laravel/horizon.git", - "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d" + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/horizon/zipball/a6ac142293ad02db4d7cccb961dd32f56ef1462d", - "reference": "a6ac142293ad02db4d7cccb961dd32f56ef1462d", + "url": "https://api.github.com/repos/laravel/horizon/zipball/009177cc3d0829c839c8ab573dd17d158fc0b71e", + "reference": "009177cc3d0829c839c8ab573dd17d158fc0b71e", "shasum": "" }, "require": { @@ -2008,39 +2026,40 @@ ], "support": { "issues": "https://github.com/laravel/horizon/issues", - "source": "https://github.com/laravel/horizon/tree/v5.47.2" + "source": "https://github.com/laravel/horizon/tree/v5.50.0" }, - "time": "2026-06-03T15:11:37+00:00" + "time": "2026-09-15T13:59:54+00:00" }, { "name": "laravel/prompts", - "version": "v0.1.25", + "version": "v0.3.24", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95" + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/7b4029a84c37cb2725fc7f011586e2997040bc95", - "reference": "7b4029a84c37cb2725fc7f011586e2997040bc95", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", "shasum": "" }, "require": { + "composer-runtime-api": "^2.2", "ext-mbstring": "*", - "illuminate/collections": "^10.0|^11.0", "php": "^8.1", - "symfony/console": "^6.2|^7.0" + "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", - "phpstan/phpstan": "^1.11", - "phpstan/phpstan-mockery": "^1.1" + "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." @@ -2048,7 +2067,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.1.x-dev" + "dev-main": "0.3.x-dev" } }, "autoload": { @@ -2066,9 +2085,9 @@ "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.1.25" + "source": "https://github.com/laravel/prompts/tree/v0.3.24" }, - "time": "2024-08-12T22:06:33+00:00" + "time": "2026-08-20T12:55:36+00:00" }, { "name": "laravel/sentinel", @@ -2128,32 +2147,32 @@ }, { "name": "laravel/serializable-closure", - "version": "v1.3.7", + "version": "v2.1.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5", + "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "php": "^8.1" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "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": "1.x-dev" + "dev-master": "2.x-dev" } }, "autoload": { @@ -2185,7 +2204,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2024-11-14T18:34:49+00:00" + "time": "2026-09-22T14:32:34+00:00" }, { "name": "laravel/tinker", @@ -2255,38 +2274,38 @@ }, { "name": "lcobucci/jwt", - "version": "5.3.0", + "version": "5.6.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", - "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", "shasum": "" }, "require": { "ext-openssl": "*", "ext-sodium": "*", - "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", "psr/clock": "^1.0" }, "require-dev": { - "infection/infection": "^0.27.0", - "lcobucci/clock": "^3.0", + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2.9", + "phpbench/phpbench": "^1.2", "phpstan/extension-installer": "^1.2", "phpstan/phpstan": "^1.10.7", "phpstan/phpstan-deprecation-rules": "^1.1.3", "phpstan/phpstan-phpunit": "^1.3.10", "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^10.2.6" + "phpunit/phpunit": "^11.1" }, "suggest": { - "lcobucci/clock": ">= 3.0" + "lcobucci/clock": ">= 3.2" }, "type": "library", "autoload": { @@ -2312,7 +2331,7 @@ ], "support": { "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" }, "funding": [ { @@ -2324,20 +2343,20 @@ "type": "patreon" } ], - "time": "2024-04-11T23:07:54+00:00" + "time": "2025-10-17T11:30:53+00:00" }, { "name": "league/commonmark", - "version": "2.8.2", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", - "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f", + "reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f", "shasum": "" }, "require": { @@ -2359,8 +2378,8 @@ "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", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.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", @@ -2374,7 +2393,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -2431,7 +2450,7 @@ "type": "tidelift" } ], - "time": "2026-03-19T13:16:38+00:00" + "time": "2026-09-21T13:07:34+00:00" }, { "name": "league/config", @@ -2517,16 +2536,16 @@ }, { "name": "league/flysystem", - "version": "3.35.1", + "version": "3.36.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c" + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f23af6c5aafd958a7593029a271d77baf5ed793c", - "reference": "f23af6c5aafd958a7593029a271d77baf5ed793c", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", "shasum": "" }, "require": { @@ -2594,22 +2613,22 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.1" + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" }, - "time": "2026-06-25T06:52:23+00:00" + "time": "2026-09-02T08:00:27+00:00" }, { "name": "league/flysystem-local", - "version": "3.31.0", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", - "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", "shasum": "" }, "require": { @@ -2643,22 +2662,22 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" }, - "time": "2026-01-23T15:30:45+00:00" + "time": "2026-08-12T13:29:21+00:00" }, { "name": "league/mime-type-detection", - "version": "1.16.0", + "version": "1.17.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", - "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", "shasum": "" }, "require": { @@ -2668,7 +2687,7 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.2", "phpstan/phpstan": "^0.12.68", - "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" }, "type": "library", "autoload": { @@ -2689,7 +2708,7 @@ "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" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" }, "funding": [ { @@ -2701,20 +2720,202 @@ "type": "tidelift" } ], - "time": "2024-09-21T08:32:55+00:00" + "time": "2026-07-09T11:49:27+00:00" }, { - "name": "maatwebsite/excel", - "version": "3.1.69", + "name": "league/uri", + "version": "7.8.1", "source": { "type": "git", - "url": "https://github.com/SpartnerNL/Laravel-Excel.git", - "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760" + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", - "reference": "ae5d65b7c9a2fac43bff4d44f796ac95d7a8e760", + "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": "maatwebsite/excel", + "version": "3.1.70", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "b5cafdfcf7ec63924e83303763be8fcae340f70b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/b5cafdfcf7ec63924e83303763be8fcae340f70b", + "reference": "b5cafdfcf7ec63924e83303763be8fcae340f70b", "shasum": "" }, "require": { @@ -2722,7 +2923,7 @@ "ext-json": "*", "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0", "php": "^7.0||^8.0", - "phpoffice/phpspreadsheet": "^1.30.4", + "phpoffice/phpspreadsheet": "^1.30.5", "psr/simple-cache": "^1.0||^2.0||^3.0" }, "require-dev": { @@ -2770,7 +2971,7 @@ ], "support": { "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", - "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.69" + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.70" }, "funding": [ { @@ -2782,35 +2983,36 @@ "type": "github" } ], - "time": "2026-04-30T20:03:58+00:00" + "time": "2026-08-13T09:11:54+00:00" }, { "name": "maennchen/zipstream-php", - "version": "3.1.1", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "6187e9cc4493da94b9b63eb2315821552015fca9" + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9", - "reference": "6187e9cc4493da94b9b63eb2315821552015fca9", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.1" + "php-64bit": "^8.2" }, "require-dev": { + "brianium/paratest": "^7.7", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^10.0", - "vimeo/psalm": "^5.0" + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" }, "suggest": { "guzzlehttp/psr7": "^2.4", @@ -2851,7 +3053,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" }, "funding": [ { @@ -2859,7 +3061,7 @@ "type": "github" } ], - "time": "2024-10-10T12:33:01+00:00" + "time": "2025-01-27T12:07:53+00:00" }, { "name": "markbaker/complex", @@ -2970,24 +3172,24 @@ }, { "name": "masterminds/html5", - "version": "2.10.1", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/Masterminds/html5-php.git", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", - "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", + "reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7", "shasum": "" }, "require": { "ext-dom": "*", - "php": ">=5.3.0" + "php": ">=7.4" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + "phpunit/phpunit": "^6 || ^7 || ^8 || ^9 || ^10" }, "type": "library", "extra": { @@ -3031,22 +3233,22 @@ ], "support": { "issues": "https://github.com/Masterminds/html5-php/issues", - "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + "source": "https://github.com/Masterminds/html5-php/tree/2.11.0" }, - "time": "2026-06-23T18:43:15+00:00" + "time": "2026-08-18T06:18:41+00:00" }, { "name": "monolog/monolog", - "version": "3.10.0", + "version": "3.12.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + "reference": "72c534fc0ab181ef52d92a68382318631e301608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", - "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/72c534fc0ab181ef52d92a68382318631e301608", + "reference": "72c534fc0ab181ef52d92a68382318631e301608", "shasum": "" }, "require": { @@ -3072,6 +3274,7 @@ "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "^10.5.17 || ^11.0.7", "predis/predis": "^1.1 || ^2", + "psr/clock": "^1.0", "rollbar/rollbar": "^4.0", "ruflin/elastica": "^7 || ^8", "symfony/mailer": "^5.4 || ^6", @@ -3090,6 +3293,7 @@ "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", + "psr/clock": "Required to pass a clock to the Logger and control the timestamp of log records", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -3124,7 +3328,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + "source": "https://github.com/Seldaek/monolog/tree/3.12.0" }, "funding": [ { @@ -3136,46 +3340,44 @@ "type": "tidelift" } ], - "time": "2026-01-02T08:56:05+00:00" + "time": "2026-09-09T08:34:20+00:00" }, { "name": "nesbot/carbon", - "version": "2.73.0", + "version": "3.14.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075" + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/9228ce90e1035ff2f0db84b40ec2e023ed802075", - "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2", + "reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2", "shasum": "" }, "require": { - "carbonphp/carbon-doctrine-types": "*", + "carbonphp/carbon-doctrine-types": "<100.0", "ext-json": "*", - "php": "^7.1.8 || ^8.0", + "php": "^8.1", "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.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": "^2.0 || ^3.1.4 || ^4.0", - "doctrine/orm": "^2.7 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.0", - "kylekatarnls/multi-tester": "^2.0", - "ondrejmirtes/better-reflection": "<6", - "phpmd/phpmd": "^2.9", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.99 || ^1.7.14", - "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", - "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", - "squizlabs/php_codesniffer": "^3.4" + "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" @@ -3218,16 +3420,16 @@ } ], "description": "An API extension for DateTime that supports 281 different languages.", - "homepage": "https://carbon.nesbot.com", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ "date", "datetime", "time" ], "support": { - "docs": "https://carbon.nesbot.com/docs", - "issues": "https://github.com/briannesbitt/Carbon/issues", - "source": "https://github.com/briannesbitt/Carbon" + "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": [ { @@ -3243,20 +3445,20 @@ "type": "tidelift" } ], - "time": "2025-01-08T20:10:23+00:00" + "time": "2026-09-12T20:45:19+00:00" }, { "name": "nette/schema", - "version": "v1.3.5", + "version": "v1.3.6", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + "reference": "c54350438cd6914616f790a49cb424605f421562" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", - "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", "shasum": "" }, "require": { @@ -3308,26 +3510,26 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.5" + "source": "https://github.com/nette/schema/tree/v1.3.6" }, - "time": "2026-02-23T03:47:12+00:00" + "time": "2026-08-16T21:58:41+00:00" }, { "name": "nette/utils", - "version": "v4.0.10", + "version": "v4.1.5", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3" + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3", - "reference": "2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", "shasum": "" }, "require": { - "php": "8.0 - 8.5" + "php": "8.2 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -3335,13 +3537,15 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/phpstan-nette": "^2.0@stable", + "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-iconv": "to use Strings::chr(), ord() 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...", @@ -3350,7 +3554,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-master": "4.1-dev" } }, "autoload": { @@ -3397,22 +3601,22 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.0.10" + "source": "https://github.com/nette/utils/tree/v4.1.5" }, - "time": "2025-12-01T17:30:42+00:00" + "time": "2026-07-17T23:02:45+00:00" }, { "name": "nikic/php-parser", - "version": "v5.8.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", - "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { @@ -3454,38 +3658,37 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2026-07-04T14:30:18+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "nunomaduro/termwind", - "version": "v1.17.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/5369ef84d8142c1d87e4ec278711d4ece3cbf301", - "reference": "5369ef84d8142c1d87e4ec278711d4ece3cbf301", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", - "php": "^8.1", - "symfony/console": "^6.4.15" + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^10.48.24", - "illuminate/support": "^10.48.24", - "laravel/pint": "^1.18.2", - "pestphp/pest": "^2.36.0", - "pestphp/pest-plugin-mock": "2.0.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^6.4.15", + "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", @@ -3494,6 +3697,9 @@ "providers": [ "Termwind\\Laravel\\TermwindServiceProvider" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -3514,7 +3720,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -3525,7 +3731,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v1.17.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -3541,33 +3747,37 @@ "type": "github" } ], - "time": "2024-11-21T10:36:35+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "nwidart/laravel-modules", - "version": "10.0.6", + "version": "v12.0.5", "source": { "type": "git", "url": "https://github.com/nWidart/laravel-modules.git", - "reference": "a6f2c8b53ae7945ef41d296735e963cee885ebde" + "reference": "5fe38e88b66394debeac785278a7e40eb81df51a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/a6f2c8b53ae7945ef41d296735e963cee885ebde", - "reference": "a6f2c8b53ae7945ef41d296735e963cee885ebde", + "url": "https://api.github.com/repos/nWidart/laravel-modules/zipball/5fe38e88b66394debeac785278a7e40eb81df51a", + "reference": "5fe38e88b66394debeac785278a7e40eb81df51a", "shasum": "" }, "require": { + "ext-dom": "*", "ext-json": "*", - "php": ">=8.1" + "ext-simplexml": "*", + "php": ">=8.2", + "wikimedia/composer-merge-plugin": "^2.1" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.6", - "laravel/framework": "^10.41", - "mockery/mockery": "^1.5", - "orchestra/testbench": "^8.0", - "phpstan/phpstan": "^1.4", - "phpunit/phpunit": "^10.0", + "friendsofphp/php-cs-fixer": "^v3.52", + "laravel/framework": "^v12.0", + "laravel/pint": "^1.16", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^v10.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.3|^12.0.", "spatie/phpunit-snapshot-assertions": "^5.0" }, "type": "library", @@ -3581,7 +3791,7 @@ ] }, "branch-alias": { - "dev-master": "10.0-dev" + "dev-master": "12.x-dev" } }, "autoload": { @@ -3614,7 +3824,7 @@ ], "support": { "issues": "https://github.com/nWidart/laravel-modules/issues", - "source": "https://github.com/nWidart/laravel-modules/tree/10.0.6" + "source": "https://github.com/nWidart/laravel-modules/tree/v12.0.5" }, "funding": [ { @@ -3626,7 +3836,7 @@ "type": "github" } ], - "time": "2024-01-28T10:04:15+00:00" + "time": "2026-03-19T18:16:17+00:00" }, { "name": "opcodesio/log-viewer", @@ -3942,16 +4152,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "1.30.5", + "version": "1.30.7", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "97bcabd32a64924688487dcd64aceaf158affb5c" + "reference": "f5dd9c1f084d336ea63cf53099533e061a9c157a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/97bcabd32a64924688487dcd64aceaf158affb5c", - "reference": "97bcabd32a64924688487dcd64aceaf158affb5c", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/f5dd9c1f084d336ea63cf53099533e061a9c157a", + "reference": "f5dd9c1f084d336ea63cf53099533e061a9c157a", "shasum": "" }, "require": { @@ -4044,9 +4254,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.5" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.7" }, - "time": "2026-05-31T05:13:11+00:00" + "time": "2026-09-17T03:56:23+00:00" }, { "name": "phpoffice/phpword", @@ -4158,16 +4368,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.5", + "version": "1.10.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", - "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", "shasum": "" }, "require": { @@ -4175,7 +4385,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" }, "type": "library", "extra": { @@ -4217,7 +4427,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" }, "funding": [ { @@ -4229,20 +4439,20 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:41:33+00:00" + "time": "2026-08-24T00:54:40+00:00" }, { "name": "phpseclib/phpseclib", - "version": "3.0.55", + "version": "3.0.57", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af" + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af", - "reference": "db9744e6d47e742b1f974e965ad49bdd041105af", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", + "reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4", "shasum": "" }, "require": { @@ -4323,7 +4533,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.55" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.57" }, "funding": [ { @@ -4339,7 +4549,54 @@ "type": "tidelift" } ], - "time": "2026-06-14T23:24:10+00:00" + "time": "2026-08-26T12:13:21+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.5", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5" + }, + "time": "2026-08-31T16:05:28+00:00" }, { "name": "plesk/ext-laravel-integration", @@ -4992,24 +5249,27 @@ }, { "name": "pusher/pusher-php-server", - "version": "7.2.8", + "version": "7.3.0", "source": { "type": "git", "url": "https://github.com/pusher/pusher-http-php.git", - "reference": "4aa139ed2a2a805cd265449b691198beee1309d2" + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2", - "reference": "4aa139ed2a2a805cd265449b691198beee1309d2", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", - "guzzlehttp/guzzle": "^7.2", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", "php": "^7.3|^8.0", - "psr/log": "^1.0|^2.0|^3.0" + "psr/http-client": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "overtrue/phplint": "^2.3", @@ -5046,9 +5306,9 @@ ], "support": { "issues": "https://github.com/pusher/pusher-http-php/issues", - "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8" + "source": "https://github.com/pusher/pusher-http-php/tree/7.3.0" }, - "time": "2026-05-18T13:11:36+00:00" + "time": "2026-08-07T12:47:11+00:00" }, { "name": "ralouphie/getallheaders", @@ -5172,20 +5432,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.3", + "version": "4.9.4", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", - "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2", + "reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2", "shasum": "" }, "require": { - "brick/math": ">=0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -5244,41 +5504,39 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "sabberworm/php-css-parser", - "version": "v9.4.0", + "version": "v9.5.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3", "shasum": "" }, "require": { "ext-iconv": "*", - "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.33 || 2.2.2", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", - "phpunit/phpunit": "8.5.52", + "phpstan/phpstan": "1.12.33 || 2.2.9", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12", + "phpunit/phpunit": "8.5.54", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.4.6", - "rector/type-perfect": "1.0.0 || 2.1.3", - "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + "rector/rector": "1.2.10 || 2.6.2", + "rector/type-perfect": "1.0.0 || 2.1.4", + "squizlabs/php_codesniffer": "4.0.4" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -5286,7 +5544,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.5.x-dev" + "dev-main": "9.6.x-dev" } }, "autoload": { @@ -5324,9 +5582,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" }, - "time": "2026-06-18T15:10:53+00:00" + "time": "2026-09-20T15:02:00+00:00" }, { "name": "spatie/laravel-permission", @@ -5414,16 +5672,16 @@ }, { "name": "swagger-api/swagger-ui", - "version": "v5.32.8", + "version": "v5.33.0", "source": { "type": "git", "url": "https://github.com/swagger-api/swagger-ui.git", - "reference": "4e0d3f88de7db0395a80ef0541ff428f0926d484" + "reference": "cfd4a6c3cbaeeb7c13a8bada7c754de42d78cd5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/swagger-api/swagger-ui/zipball/4e0d3f88de7db0395a80ef0541ff428f0926d484", - "reference": "4e0d3f88de7db0395a80ef0541ff428f0926d484", + "url": "https://api.github.com/repos/swagger-api/swagger-ui/zipball/cfd4a6c3cbaeeb7c13a8bada7c754de42d78cd5b", + "reference": "cfd4a6c3cbaeeb7c13a8bada7c754de42d78cd5b", "shasum": "" }, "type": "library", @@ -5469,53 +5727,131 @@ ], "support": { "issues": "https://github.com/swagger-api/swagger-ui/issues", - "source": "https://github.com/swagger-api/swagger-ui/tree/v5.32.8" + "source": "https://github.com/swagger-api/swagger-ui/tree/v5.33.0" }, - "time": "2026-06-23T08:15:35+00:00" + "time": "2026-09-16T09:18:16+00:00" }, { - "name": "symfony/console", - "version": "v6.4.42", + "name": "symfony/clock", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "9ef84af84a7b66396da483634227650506428639" + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9ef84af84a7b66396da483634227650506428639", - "reference": "9ef84af84a7b66396da483634227650506428639", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "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/v7.4.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-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.19", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "3a1973458b153f566a8d7499bc06ece323d60063" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063", + "reference": "3a1973458b153f566a8d7499bc06ece323d60063", + "shasum": "" + }, + "require": { + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^5.4|^6.0|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<5.4", - "symfony/dotenv": "<5.4", - "symfony/event-dispatcher": "<5.4", - "symfony/lock": "<5.4", - "symfony/process": "<5.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5549,7 +5885,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.42" + "source": "https://github.com/symfony/console/tree/v7.4.19" }, "funding": [ { @@ -5569,24 +5905,24 @@ "type": "tidelift" } ], - "time": "2026-06-15T05:35:29+00:00" + "time": "2026-09-13T10:38:41+00:00" }, { "name": "symfony/css-selector", - "version": "v6.4.34", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b0314c186f1464de048cce58979ff1625ca88bbb" + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b0314c186f1464de048cce58979ff1625ca88bbb", - "reference": "b0314c186f1464de048cce58979ff1625ca88bbb", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/fecf40067fc8d8880ea87b8ac227600b9aadd1d0", + "reference": "fecf40067fc8d8880ea87b8ac227600b9aadd1d0", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -5618,7 +5954,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v6.4.34" + "source": "https://github.com/symfony/css-selector/tree/v7.4.18" }, "funding": [ { @@ -5638,7 +5974,7 @@ "type": "tidelift" } ], - "time": "2026-02-16T08:37:21+00:00" + "time": "2026-08-23T10:03:40+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5713,31 +6049,34 @@ }, { "name": "symfony/error-handler", - "version": "v6.4.36", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "2ea68f0e1835ad6a126f93bbc14cd236c10ab361" + "reference": "8373921e231e190a88e2ad526951bbaa791576fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/2ea68f0e1835ad6a126f93bbc14cd236c10ab361", - "reference": "2ea68f0e1835ad6a126f93bbc14cd236c10ab361", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8373921e231e190a88e2ad526951bbaa791576fa", + "reference": "8373921e231e190a88e2ad526951bbaa791576fa", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^5.4|^6.0|^7.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -5768,7 +6107,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v6.4.36" + "source": "https://github.com/symfony/error-handler/tree/v7.4.17" }, "funding": [ { @@ -5788,28 +6127,28 @@ "type": "tidelift" } ], - "time": "2026-03-10T15:56:14+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.37", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1" + "reference": "d269974ee93c61d03620ffee358355bfdb471d66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2e3bf817ba9347341ab15926700fb6320367c0e1", - "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/d269974ee93c61d03620ffee358355bfdb471d66", + "reference": "d269974ee93c61d03620ffee358355bfdb471d66", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<5.4", + "symfony/dependency-injection": "<6.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5818,13 +6157,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^5.4|^6.0|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5852,7 +6192,7 @@ "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/v6.4.37" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.17" }, "funding": [ { @@ -5872,7 +6212,7 @@ "type": "tidelift" } ], - "time": "2026-04-13T14:11:12+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -5956,23 +6296,23 @@ }, { "name": "symfony/finder", - "version": "v6.4.42", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "0b73dac42493acbadbba644207a715b254e9b029" + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", - "reference": "0b73dac42493acbadbba644207a715b254e9b029", + "url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe", + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.0|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6000,7 +6340,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.42" + "source": "https://github.com/symfony/finder/tree/v7.4.19" }, "funding": [ { @@ -6020,40 +6360,41 @@ "type": "tidelift" } ], - "time": "2026-06-26T15:18:24+00:00" + "time": "2026-09-10T19:14:10+00:00" }, { "name": "symfony/http-foundation", - "version": "v6.4.42", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "23dcf8e0f59cbbfa9d2894f58fd8be6833794372" + "reference": "f7b953be7801974a5a0685e1228bbc3b12b35936" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dcf8e0f59cbbfa9d2894f58fd8be6833794372", - "reference": "23dcf8e0f59cbbfa9d2894f58fd8be6833794372", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936", + "reference": "f7b953be7801974a5a0685e1228bbc3b12b35936", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { + "doctrine/dbal": "<3.6", "symfony/cache": "<6.4.12|>=7.0,<7.1.5" }, "require-dev": { - "doctrine/dbal": "^2.13.1|^3|^4", + "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", - "symfony/mime": "^5.4|^6.0|^7.0", - "symfony/rate-limiter": "^5.4|^6.0|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6081,7 +6422,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.42" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.19" }, "funding": [ { @@ -6101,77 +6442,78 @@ "type": "tidelift" } ], - "time": "2026-06-16T10:12:24+00:00" + "time": "2026-09-14T17:41:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.42", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "51fd5bb7d4f221ceeac927e14ade63962e27a47c" + "reference": "683507424cc84ca85ac1551adb609b6b98216667" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/51fd5bb7d4f221ceeac927e14ade63962e27a47c", - "reference": "51fd5bb7d4f221ceeac927e14ade63962e27a47c", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667", + "reference": "683507424cc84ca85ac1551adb609b6b98216667", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.4", - "symfony/config": "<6.1", - "symfony/console": "<5.4", + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<5.4", - "symfony/form": "<5.4", - "symfony/http-client": "<5.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<5.4", - "symfony/messenger": "<5.4", - "symfony/translation": "<5.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<5.4", + "symfony/twig-bridge": "<6.4", "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.3", - "twig/twig": "<2.13" + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^5.4|^6.0|^7.0", - "symfony/clock": "^6.2|^7.0", - "symfony/config": "^6.1|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/css-selector": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1", - "symfony/dom-crawler": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/property-access": "^5.4.5|^6.0.5|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.4|^7.0.4", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^5.4|^6.0|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.4|^7.0", - "symfony/var-exporter": "^6.2|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "type": "library", "autoload": { @@ -6199,7 +6541,7 @@ "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/v6.4.42" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.19" }, "funding": [ { @@ -6219,43 +6561,43 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:10:20+00:00" + "time": "2026-09-15T07:09:35+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.40", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "94fd44f3052e02340b0dd4447a7d7a5856e32da2" + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/94fd44f3052e02340b0dd4447a7d7a5856e32da2", - "reference": "94fd44f3052e02340b0dd4447a7d7a5856e32da2", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.1", + "php": ">=8.2", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/mime": "^6.2|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", - "symfony/messenger": "<6.2", - "symfony/mime": "<6.2", - "symfony/twig-bridge": "<6.2.1" + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/messenger": "^6.2|^7.0", - "symfony/twig-bridge": "^6.2|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6283,7 +6625,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.40" + "source": "https://github.com/symfony/mailer/tree/v7.4.19" }, "funding": [ { @@ -6303,44 +6645,44 @@ "type": "tidelift" } ], - "time": "2026-05-19T20:33:22+00:00" + "time": "2026-09-15T06:01:15+00:00" }, { "name": "symfony/mime", - "version": "v6.4.41", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e" + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/5575d37f8841e4e31d5df79ab3db078ae557ff8e", - "reference": "5575d37f8841e4e31d5df79ab3db078ae557ff8e", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", - "phpdocumentor/reflection-docblock": "<3.2.2", - "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<5.4", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", "symfony/serializer": "<6.4.3|>7.0,<7.0.3" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", - "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.4|^7.0", - "symfony/property-access": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.44|^7.4.17|^8.1.5" }, "type": "library", "autoload": { @@ -6372,7 +6714,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.41" + "source": "https://github.com/symfony/mime/tree/v7.4.19" }, "funding": [ { @@ -6392,7 +6734,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T14:40:34+00:00" + "time": "2026-09-04T10:45:44+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6479,16 +6821,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -6537,7 +6879,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -6557,20 +6899,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.38.1", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "dc21118016c039a66235cf93d96b435ffb282412" + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", - "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", "shasum": "" }, "require": { @@ -6624,7 +6966,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" }, "funding": [ { @@ -6644,20 +6986,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T15:22:23+00:00" + "time": "2026-08-24T10:51:20+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.42.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", "shasum": "" }, "require": { @@ -6709,7 +7051,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" }, "funding": [ { @@ -6729,7 +7071,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-08-07T06:33:24+00:00" }, { "name": "symfony/polyfill-mbstring", @@ -6902,16 +7244,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", "shasum": "" }, "require": { @@ -6958,7 +7300,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" }, "funding": [ { @@ -6978,7 +7320,167 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "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.38.1" + }, + "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-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "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.41.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-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-uuid", @@ -7065,20 +7567,20 @@ }, { "name": "symfony/process", - "version": "v6.4.41", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", - "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e", + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e", "shasum": "" }, "require": { - "php": ">=8.1" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -7106,7 +7608,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.41" + "source": "https://github.com/symfony/process/tree/v7.4.19" }, "funding": [ { @@ -7126,40 +7628,38 @@ "type": "tidelift" } ], - "time": "2026-05-23T13:47:21+00:00" + "time": "2026-09-02T12:38:34+00:00" }, { "name": "symfony/routing", - "version": "v6.4.41", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e" + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/af04c79671fd8df0805a44c83fa2b0ba56c8329e", - "reference": "af04c79671fd8df0805a44c83fa2b0ba56c8329e", + "url": "https://api.github.com/repos/symfony/routing/zipball/ddd558991e98f693ae6bf5063cc1b0362c6bbec3", + "reference": "ddd558991e98f693ae6bf5063cc1b0362c6bbec3", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "doctrine/annotations": "<1.12", - "symfony/config": "<6.2", - "symfony/dependency-injection": "<5.4", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "psr/log": "^1|^2|^3", - "symfony/config": "^6.2|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7193,7 +7693,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.41" + "source": "https://github.com/symfony/routing/tree/v7.4.18" }, "funding": [ { @@ -7213,20 +7713,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T11:18:16+00:00" + "time": "2026-08-17T13:12:36+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -7280,7 +7780,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -7300,26 +7800,27 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "v6.4.39", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "62e3c927de664edadb5bef260987eb047a17a113" + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", - "reference": "62e3c927de664edadb5bef260987eb047a17a113", + "url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46", + "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -7327,10 +7828,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7369,7 +7871,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.39" + "source": "https://github.com/symfony/string/tree/v7.4.19" }, "funding": [ { @@ -7389,55 +7891,56 @@ "type": "tidelift" } ], - "time": "2026-05-12T11:44:19+00:00" + "time": "2026-09-11T14:46:58+00:00" }, { "name": "symfony/translation", - "version": "v6.4.42", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "fef99cef37890b350976f5f492854faefadd4e15" + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/fef99cef37890b350976f5f492854faefadd4e15", - "reference": "fef99cef37890b350976f5f492854faefadd4e15", + "url": "https://api.github.com/repos/symfony/translation/zipball/2ee1e4a3b32a528a642babe041ff7c440b213b4b", + "reference": "2ee1e4a3b32a528a642babe041ff7c440b213b4b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { - "symfony/config": "<5.4", - "symfony/console": "<5.4", - "symfony/dependency-injection": "<5.4", + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<5.4", + "symfony/http-kernel": "<6.4", "symfony/service-contracts": "<2.5", - "symfony/twig-bundle": "<5.4", - "symfony/yaml": "<5.4" + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { - "nikic/php-parser": "^4.18|^5.0", + "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^5.4|^6.0|^7.0", - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/dependency-injection": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7468,7 +7971,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.42" + "source": "https://github.com/symfony/translation/tree/v7.4.17" }, "funding": [ { @@ -7488,7 +7991,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T16:46:18+00:00" + "time": "2026-08-21T17:40:08+00:00" }, { "name": "symfony/translation-contracts", @@ -7574,24 +8077,24 @@ }, { "name": "symfony/uid", - "version": "v6.4.32", + "version": "v7.4.17", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "6b973c385f00341b246f697d82dc01a09107acdd" + "reference": "69d732355a139c6f8881337d28515aa01f12b8be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/6b973c385f00341b246f697d82dc01a09107acdd", - "reference": "6b973c385f00341b246f697d82dc01a09107acdd", + "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be", + "reference": "69d732355a139c6f8881337d28515aa01f12b8be", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7628,7 +8131,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v6.4.32" + "source": "https://github.com/symfony/uid/tree/v7.4.17" }, "funding": [ { @@ -7648,37 +8151,36 @@ "type": "tidelift" } ], - "time": "2025-12-23T15:07:59+00:00" + "time": "2026-08-11T07:38:58+00:00" }, { "name": "symfony/var-dumper", - "version": "v6.4.42", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "29adc5e34255f42ca9b8ae61c22b4d9e3f611317" + "reference": "e088da50b813f32473a76871616cbb8fa54653a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/29adc5e34255f42ca9b8ae61c22b4d9e3f611317", - "reference": "29adc5e34255f42ca9b8ae61c22b4d9e3f611317", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e088da50b813f32473a76871616cbb8fa54653a8", + "reference": "e088da50b813f32473a76871616cbb8fa54653a8", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0", - "symfony/error-handler": "^6.3|^7.0", - "symfony/http-kernel": "^5.4|^6.0|^7.0", - "symfony/process": "^5.4|^6.0|^7.0", - "symfony/uid": "^5.4|^6.0|^7.0", - "twig/twig": "^2.13|^3.0.4" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -7716,7 +8218,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.42" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.18" }, "funding": [ { @@ -7736,32 +8238,32 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" + "time": "2026-08-30T20:10:52+00:00" }, { "name": "symfony/yaml", - "version": "v6.4.42", + "version": "v7.4.18", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c" + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/989dfb75049b77884f3f8cf9e99f103c8f91ca1c", - "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cef939e55b8a21c5780418de0d98ab00d0736e1", + "reference": "4cef939e55b8a21c5780418de0d98ab00d0736e1", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.4" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.4|^6.0|^7.0" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -7792,7 +8294,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.42" + "source": "https://github.com/symfony/yaml/tree/v7.4.18" }, "funding": [ { @@ -7812,150 +8314,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T07:06:12+00:00" - }, - { - "name": "thecodingmachine/safe", - "version": "v3.4.0", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-02-04T18:08:13+00:00" + "time": "2026-08-30T00:47:26+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8092,23 +8451,23 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.3", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "955e7815d677a3eaa7075231212f2110983adecc" + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", - "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.4", + "graham-campbell/result-type": "^1.2", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.5", + "phpoption/phpoption": "^1.10", "symfony/polyfill-ctype": "^1.26", "symfony/polyfill-mbstring": "^1.26", "symfony/polyfill-php80": "^1.26" @@ -8152,7 +8511,7 @@ "homepage": "https://github.com/vlucas" } ], - "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", "keywords": [ "dotenv", "env", @@ -8160,7 +8519,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" }, "funding": [ { @@ -8172,7 +8531,7 @@ "type": "tidelift" } ], - "time": "2025-12-27T19:49:13+00:00" + "time": "2026-08-24T18:07:49+00:00" }, { "name": "voku/portable-ascii", @@ -8249,37 +8608,100 @@ "time": "2026-04-26T05:33:54+00:00" }, { - "name": "zircote/swagger-php", - "version": "4.11.1", + "name": "wikimedia/composer-merge-plugin", + "version": "v2.1.0", "source": { "type": "git", - "url": "https://github.com/zircote/swagger-php.git", - "reference": "7df10e8ec47db07c031db317a25bef962b4e5de1" + "url": "https://github.com/wikimedia/composer-merge-plugin.git", + "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/7df10e8ec47db07c031db317a25bef962b4e5de1", - "reference": "7df10e8ec47db07c031db317a25bef962b4e5de1", + "url": "https://api.github.com/repos/wikimedia/composer-merge-plugin/zipball/a03d426c8e9fb2c9c569d9deeb31a083292788bc", + "reference": "a03d426c8e9fb2c9c569d9deeb31a083292788bc", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1||^2.0", + "php": ">=7.2.0" + }, + "require-dev": { + "composer/composer": "^1.1||^2.0", + "ext-json": "*", + "mediawiki/mediawiki-phan-config": "0.11.1", + "php-parallel-lint/php-parallel-lint": "~1.3.1", + "phpspec/prophecy": "~1.15.0", + "phpunit/phpunit": "^8.5||^9.0", + "squizlabs/php_codesniffer": "~3.7.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Wikimedia\\Composer\\Merge\\V2\\MergePlugin", + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Wikimedia\\Composer\\Merge\\V2\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bryan Davis", + "email": "bd808@wikimedia.org" + } + ], + "description": "Composer plugin to merge multiple composer.json files", + "support": { + "issues": "https://github.com/wikimedia/composer-merge-plugin/issues", + "source": "https://github.com/wikimedia/composer-merge-plugin/tree/v2.1.0" + }, + "time": "2023-04-15T19:07:00+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "5.8.3", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "098223019f764a16715f64089a58606096719c98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/098223019f764a16715f64089a58606096719c98", + "reference": "098223019f764a16715f64089a58606096719c98", "shasum": "" }, "require": { "ext-json": "*", - "php": ">=7.2", + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=7.4", + "phpstan/phpdoc-parser": "^2.0", "psr/log": "^1.1 || ^2.0 || ^3.0", "symfony/deprecation-contracts": "^2 || ^3", - "symfony/finder": ">=2.2", - "symfony/yaml": ">=3.3" + "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" }, "require-dev": { "composer/package-versions-deprecated": "^1.11", - "doctrine/annotations": "^1.7 || ^2.0", - "friendsofphp/php-cs-fixer": "^2.17 || 3.62.0", - "phpstan/phpstan": "^1.6", - "phpunit/phpunit": ">=8", - "vimeo/psalm": "^4.23" + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^1.6 || ^2.0", + "phpunit/phpunit": "^9.0", + "rector/rector": "^1.0 || ^2.3.1", + "vimeo/psalm": "^4.30 || ^5.0" }, "suggest": { - "doctrine/annotations": "^1.7 || ^2.0" + "doctrine/annotations": "^2.0", + "radebatz/type-info-extras": "^1.0.2" }, "bin": [ "bin/openapi" @@ -8287,7 +8709,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.x-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -8315,8 +8737,8 @@ "homepage": "https://radebatz.net" } ], - "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", - "homepage": "https://github.com/zircote/swagger-php/", + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", "keywords": [ "api", "json", @@ -8325,82 +8747,18 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/4.11.1" - }, - "time": "2024-10-15T19:20:02+00:00" - } - ], - "packages-dev": [ - { - "name": "doctrine/instantiator", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "doctrine/coding-standard": "^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "^1.9.4", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5.27", - "vimeo/psalm": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", - "keywords": [ - "constructor", - "instantiate" - ], - "support": { - "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + "source": "https://github.com/zircote/swagger-php/tree/5.8.3" }, "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%2Finstantiator", - "type": "tidelift" + "url": "https://github.com/zircote", + "type": "github" } ], - "time": "2022-12-30T00:23:10+00:00" - }, + "time": "2026-03-02T00:47:18+00:00" + } + ], + "packages-dev": [ { "name": "fakerphp/faker", "version": "v1.24.1", @@ -8466,16 +8824,16 @@ }, { "name": "filp/whoops", - "version": "2.18.4", + "version": "2.18.5", "source": { "type": "git", "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/filp/whoops/zipball/baec851ad6ae00db8bd0fdd30d208afc1b71c56e", + "reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e", "shasum": "" }, "require": { @@ -8484,8 +8842,8 @@ }, "require-dev": { "mockery/mockery": "^1.0", - "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^4.0 || ^5.0" + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58", + "symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "symfony/var-dumper": "Pretty print complex values better with var-dumper available", @@ -8525,7 +8883,7 @@ ], "support": { "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.18.4" + "source": "https://github.com/filp/whoops/tree/2.18.5" }, "funding": [ { @@ -8533,23 +8891,25 @@ "type": "github" } ], - "time": "2025-08-08T12:00:00+00:00" + "time": "2026-09-18T12:00:00+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.1.1", + "version": "v3.0.0", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", - "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-dom": "*", "php": "^7.4|^8.0" }, "replace": { @@ -8558,13 +8918,15 @@ "kodova/hamcrest-php": "*" }, "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "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" + "dev-master": "3.0-dev" } }, "autoload": { @@ -8582,22 +8944,22 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" }, - "time": "2025-04-30T06:54:44+00:00" + "time": "2026-03-17T11:56:53+00:00" }, { "name": "laravel/sail", - "version": "v1.63.0", + "version": "v1.68.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc" + "reference": "2bc304083d515065b03944e425e62cb3c526c33e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/51bbce3f803c1d386cabbb44e618c955a12ff5fc", - "reference": "51bbce3f803c1d386cabbb44e618c955a12ff5fc", + "url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e", + "reference": "2bc304083d515065b03944e425e62cb3c526c33e", "shasum": "" }, "require": { @@ -8647,33 +9009,32 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-06-18T08:54:14+00:00" + "time": "2026-09-18T14:34:46+00:00" }, { "name": "mockery/mockery", - "version": "1.6.12", + "version": "1.6.15", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", "shasum": "" }, "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", + "hamcrest/hamcrest-php": "^2.0 || ^3.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" + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" }, "type": "library", "autoload": { @@ -8730,24 +9091,24 @@ "security": "https://github.com/mockery/mockery/security/advisories", "source": "https://github.com/mockery/mockery" }, - "time": "2024-05-16T03:13:13+00:00" + "time": "2026-08-19T19:37:52+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.13.4", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.0" }, "conflict": { "doctrine/collections": "<1.6.8", @@ -8782,52 +9143,48 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" + "url": "https://github.com/mnapoli", + "type": "github" } ], - "time": "2025-08-01T08:46:24+00:00" + "time": "2026-08-11T10:17:44+00:00" }, { "name": "nunomaduro/collision", - "version": "v7.12.0", + "version": "v8.9.5", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a" + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/995245421d3d7593a6960822063bdba4f5d7cf1a", - "reference": "995245421d3d7593a6960822063bdba4f5d7cf1a", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", "shasum": "" }, "require": { - "filp/whoops": "^2.17.0", - "nunomaduro/termwind": "^1.17.0", - "php": "^8.1.0", - "symfony/console": "^6.4.17" + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" }, "conflict": { - "laravel/framework": ">=11.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.4.8", - "laravel/framework": "^10.48.29", - "laravel/pint": "^1.21.2", - "laravel/sail": "^1.41.0", - "laravel/sanctum": "^3.3.3", - "laravel/tinker": "^2.10.1", - "nunomaduro/larastan": "^2.10.0", - "orchestra/testbench-core": "^8.35.0", - "pestphp/pest": "^2.36.0", - "phpunit/phpunit": "^10.5.36", - "sebastian/environment": "^6.1.0", - "spatie/laravel-ignition": "^2.9.1" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" }, "type": "library", "extra": { @@ -8835,6 +9192,9 @@ "providers": [ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" } }, "autoload": { @@ -8861,6 +9221,7 @@ "cli", "command-line", "console", + "dev", "error", "handling", "laravel", @@ -8886,7 +9247,7 @@ "type": "patreon" } ], - "time": "2025-03-14T22:35:49+00:00" + "time": "2026-07-15T19:09:14+00:00" }, { "name": "phar-io/manifest", @@ -9008,35 +9369,35 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.19.1 || ^5.1.0", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-text-template": "^2.0.4", - "sebastian/code-unit-reverse-lookup": "^2.0.3", - "sebastian/complexity": "^2.0.3", - "sebastian/environment": "^5.1.5", - "sebastian/lines-of-code": "^1.0.4", - "sebastian/version": "^3.0.2", - "theseer/tokenizer": "^1.2.3" + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -9045,7 +9406,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.2.x-dev" + "dev-main": "11.0.x-dev" } }, "autoload": { @@ -9074,40 +9435,52 @@ "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/9.2.32" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "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": "2024-08-22T04:23:01+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -9134,36 +9507,49 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "suggest": { "ext-pcntl": "*" @@ -9171,7 +9557,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -9197,7 +9583,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" }, "funding": [ { @@ -9205,32 +9592,32 @@ "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2024-07-03T05:07:44+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9256,7 +9643,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" }, "funding": [ { @@ -9264,32 +9652,32 @@ "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2024-07-03T05:08:43+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -9315,7 +9703,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" }, "funding": [ { @@ -9323,54 +9712,53 @@ "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2024-07-03T05:09:35+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.34", + "version": "11.5.56", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "b36f02317466907a230d3aa1d34467041271ef4a" + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", - "reference": "b36f02317466907a230d3aa1d34467041271ef4a", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "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": ">=7.3", - "phpunit/php-code-coverage": "^9.2.32", - "phpunit/php-file-iterator": "^3.0.6", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.4", - "phpunit/php-timer": "^5.0.3", - "sebastian/cli-parser": "^1.0.2", - "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.10", - "sebastian/diff": "^4.0.6", - "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.8", - "sebastian/global-state": "^5.0.8", - "sebastian/object-enumerator": "^4.0.4", - "sebastian/resource-operations": "^3.0.4", - "sebastian/type": "^3.2.1", - "sebastian/version": "^3.0.2" + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" }, "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "ext-soap": "To be able to generate mocks based on WSDL files" }, "bin": [ "phpunit" @@ -9378,7 +9766,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "11.5-dev" } }, "autoload": { @@ -9410,56 +9798,40 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "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/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-01-27T05:45:00+00:00" + "time": "2026-07-06T14:52:39+00:00" }, { "name": "sebastian/cli-parser", - "version": "1.0.2", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", - "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9482,7 +9854,8 @@ "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -9490,32 +9863,32 @@ "type": "github" } ], - "time": "2024-03-02T06:27:43+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { "name": "sebastian/code-unit", - "version": "1.0.8", + "version": "3.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -9538,7 +9911,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" }, "funding": [ { @@ -9546,32 +9920,32 @@ "type": "github" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2025-03-19T07:56:08+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9593,7 +9967,8 @@ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -9601,34 +9976,39 @@ "type": "github" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.10", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -9667,7 +10047,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -9687,33 +10068,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:22:56+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.3", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -9736,7 +10117,8 @@ "homepage": "https://github.com/sebastianbergmann/complexity", "support": { "issues": "https://github.com/sebastianbergmann/complexity/issues", - "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ { @@ -9744,33 +10126,33 @@ "type": "github" } ], - "time": "2023-12-22T06:19:30+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { "name": "sebastian/diff", - "version": "4.0.6", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", - "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^11.0", "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -9802,7 +10184,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", - "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -9810,27 +10193,27 @@ "type": "github" } ], - "time": "2024-03-02T06:30:58+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -9838,7 +10221,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -9857,7 +10240,7 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", @@ -9865,42 +10248,55 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.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/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.8", + "version": "6.3.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", - "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.3-dev" } }, "autoload": { @@ -9942,7 +10338,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" }, "funding": [ { @@ -9962,38 +10359,35 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:03:27+00:00" + "time": "2025-09-24T06:12:51+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.8", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", - "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -10012,59 +10406,48 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.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-10T07:10:35+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.4", + "version": "3.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", - "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { - "nikic/php-parser": "^4.18 || ^5.0", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -10087,7 +10470,8 @@ "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -10095,34 +10479,34 @@ "type": "github" } ], - "time": "2023-12-22T06:20:34+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10144,7 +10528,8 @@ "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -10152,32 +10537,32 @@ "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -10199,7 +10584,8 @@ "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -10207,32 +10593,32 @@ "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.6", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", - "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -10262,7 +10648,8 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" }, "funding": [ { @@ -10282,86 +10669,32 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:57:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.4", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "support": { - "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-03-14T16:00:52+00:00" + "time": "2025-08-13T04:42:22+00:00" }, { "name": "sebastian/type", - "version": "3.2.1", + "version": "5.1.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -10384,37 +10717,50 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.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/type", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-08-09T06:55:48+00:00" }, { "name": "sebastian/version", - "version": "3.0.2", + "version": "5.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -10437,7 +10783,8 @@ "homepage": "https://github.com/sebastianbergmann/version", "support": { "issues": "https://github.com/sebastianbergmann/version/issues", - "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -10445,7 +10792,7 @@ "type": "github" } ], - "time": "2020-09-28T06:39:44+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { "name": "spatie/backtrace", @@ -10742,38 +11089,39 @@ }, { "name": "spatie/laravel-ignition", - "version": "2.9.1", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/spatie/laravel-ignition.git", - "reference": "1baee07216d6748ebd3a65ba97381b051838707a" + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/1baee07216d6748ebd3a65ba97381b051838707a", - "reference": "1baee07216d6748ebd3a65ba97381b051838707a", + "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee", + "reference": "45b3b6e1e73fc161cba2149972698644b99594ee", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1", - "spatie/ignition": "^1.15", - "symfony/console": "^6.2.3|^7.0", - "symfony/var-dumper": "^6.2.3|^7.0" + "illuminate/support": "^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.72|^3.0", + "php": "^8.2", + "spatie/ignition": "^1.16", + "symfony/console": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "require-dev": { - "livewire/livewire": "^2.11|^3.3.5", - "mockery/mockery": "^1.5.1", - "openai-php/client": "^0.8.1|^0.10", - "orchestra/testbench": "8.22.3|^9.0|^10.0", - "pestphp/pest": "^2.34|^3.7", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan-deprecation-rules": "^1.1.1|^2.0", - "phpstan/phpstan-phpunit": "^1.3.16|^2.0", - "vlucas/phpdotenv": "^5.5" + "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support", + "mockery/mockery": "^1.6.12", + "openai-php/client": "^0.10.3|^0.19", + "orchestra/testbench": "^v9.16.0|^10.6|^11.0", + "pestphp/pest": "^3.7|^4.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.8", + "vlucas/phpdotenv": "^5.6.2" }, "suggest": { "openai-php/client": "Require get solutions from OpenAI", @@ -10829,7 +11177,59 @@ "type": "github" } ], - "time": "2025-02-20T13:13:55+00:00" + "time": "2026-03-17T12:20:04+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", @@ -10888,12 +11288,12 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.1", + "php": "^8.2", "ext-openssl": "*" }, "platform-dev": {}, "platform-overrides": { - "php": "8.1.0" + "php": "8.2.0" }, "plugin-api-version": "2.9.0" } diff --git a/config/app.php b/config/app.php index b96ee40..dffc0cd 100644 --- a/config/app.php +++ b/config/app.php @@ -128,6 +128,8 @@ return [ 'key' => env('APP_KEY'), + 'previous_keys' => array_filter(explode(',', (string) env('APP_PREVIOUS_KEYS', ''))), + 'cipher' => 'AES-256-CBC', /* diff --git a/config/l5-swagger.php b/config/l5-swagger.php index 2394392..46ee563 100644 --- a/config/l5-swagger.php +++ b/config/l5-swagger.php @@ -67,9 +67,9 @@ return [ * Middleware allows to prevent unexpected access to API documentation */ 'middleware' => [ - 'api' => [], - 'asset' => [], - 'docs' => [], + 'api' => ['restrict.local'], + 'asset' => ['restrict.local'], + 'docs' => ['restrict.local'], 'oauth2_callback' => [], ], diff --git a/config/rsa.php b/config/rsa.php index c7bf643..7b3930b 100644 --- a/config/rsa.php +++ b/config/rsa.php @@ -4,4 +4,6 @@ return [ 'key' => env('RSA_KEY'), 'public' => env('RSA_PUBLIC'), 'private' => env('RSA_PRIVATE'), + 'previous_key' => env('RSA_PREVIOUS_KEY'), + 'previous_public' => env('RSA_PREVIOUS_PUBLIC'), ]; diff --git a/config/services.php b/config/services.php index f3da16f..3acfc1f 100644 --- a/config/services.php +++ b/config/services.php @@ -30,6 +30,11 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'plesk' => [ + 'tls_verify' => env('PLESK_TLS_VERIFY', true), + 'ca_bundle' => env('PLESK_CA_BUNDLE'), + ], + 'nextjs' => [ 'url' => env('NEXTJS_URL', 'http://localhost:3000'), 'revalidate_secret' => env('NEXTJS_REVALIDATE_SECRET'), diff --git a/config/vmware.php b/config/vmware.php index ae35ca3..154ea82 100644 --- a/config/vmware.php +++ b/config/vmware.php @@ -4,6 +4,16 @@ return [ // Default password baked into every VM template at clone time. 'template_password' => env('VMWARE_TEMPLATE_PASSWORD', 'TGS@123'), + // TLS verification for vCenter/ESXi REST calls and PowerCLI sessions. + // Default true (secure). Set VCENTER_TLS_VERIFY=false only where the + // jump host truly cannot validate the vCenter cert yet. + 'tls_verify' => env('VCENTER_TLS_VERIFY', true), + 'ca_bundle' => env('VCENTER_CA_BUNDLE'), + + // known_hosts file for SSH to the jump host and vSphere hosts. Required + // while tls_verify is true; skipped only when tls_verify is false. + 'ssh_known_hosts' => env('VCENTER_SSH_KNOWN_HOSTS'), + // HAProxy (console.tgs.com.vn) routes WMKS console websockets to the // ESXi host that owns the ticket via the `?host=` query param — see // ha-proxy.md. This maps each ESXi IP to the domain HAProxy matches on. diff --git a/database/migrations/2026_09_26_000001_add_requirements_and_feedback_to_services_table.php b/database/migrations/2026_09_26_000001_add_requirements_and_feedback_to_services_table.php new file mode 100644 index 0000000..5cebf92 --- /dev/null +++ b/database/migrations/2026_09_26_000001_add_requirements_and_feedback_to_services_table.php @@ -0,0 +1,23 @@ +longText('requirements')->nullable()->after('note'); + $table->longText('feedback')->nullable()->after('requirements'); + }); + } + + public function down(): void + { + Schema::table('services', function (Blueprint $table) { + $table->dropColumn(['requirements', 'feedback']); + }); + } +}; diff --git a/database/migrations/2026_09_26_100000_add_revoked_at_to_refresh_tokens_table.php b/database/migrations/2026_09_26_100000_add_revoked_at_to_refresh_tokens_table.php new file mode 100644 index 0000000..5d3e322 --- /dev/null +++ b/database/migrations/2026_09_26_100000_add_revoked_at_to_refresh_tokens_table.php @@ -0,0 +1,22 @@ +timestamp('revoked_at')->nullable()->after('expires_at'); + }); + } + + public function down(): void + { + Schema::table('refresh_tokens', function (Blueprint $table) { + $table->dropColumn('revoked_at'); + }); + } +}; diff --git a/database/migrations/2026_09_26_100000_encrypt_reinstall_logs_plain_password.php b/database/migrations/2026_09_26_100000_encrypt_reinstall_logs_plain_password.php new file mode 100644 index 0000000..bb62fc0 --- /dev/null +++ b/database/migrations/2026_09_26_100000_encrypt_reinstall_logs_plain_password.php @@ -0,0 +1,50 @@ +whereNotNull('plain_password') + ->where('plain_password', '!=', '') + ->orderBy('id') + ->chunkById(200, function ($rows) { + foreach ($rows as $row) { + try { + Crypt::decryptString($row->plain_password); + continue; + } catch (\Throwable) { + // not yet encrypted — fall through and encrypt it + } + + DB::table('reinstall_logs') + ->where('id', $row->id) + ->update(['plain_password' => Crypt::encryptString($row->plain_password)]); + } + }); + } + + public function down(): void + { + DB::table('reinstall_logs') + ->whereNotNull('plain_password') + ->where('plain_password', '!=', '') + ->orderBy('id') + ->chunkById(200, function ($rows) { + foreach ($rows as $row) { + try { + $plain = Crypt::decryptString($row->plain_password); + } catch (\Throwable) { + continue; + } + + DB::table('reinstall_logs') + ->where('id', $row->id) + ->update(['plain_password' => $plain]); + } + }); + } +}; diff --git a/database/migrations/2026_09_26_150000_add_upgrade_columns_to_invoice_tables.php b/database/migrations/2026_09_26_150000_add_upgrade_columns_to_invoice_tables.php new file mode 100644 index 0000000..c4e7daf --- /dev/null +++ b/database/migrations/2026_09_26_150000_add_upgrade_columns_to_invoice_tables.php @@ -0,0 +1,38 @@ +unsignedBigInteger('service_id')->nullable()->after('order_id')->index() + ->comment('Service this invoice upgrades; set for invoice_type=3 (upgrade)'); + + $table->foreign('service_id')->references('id')->on('services')->onDelete('set null'); + }); + + Schema::table('invoice_items', function (Blueprint $table) { + $table->unsignedBigInteger('addon_id')->nullable()->after('product_id')->index() + ->comment('Addon purchased on this line; set for type=upgrade'); + + $table->foreign('addon_id')->references('id')->on('product_addon')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('invoice_items', function (Blueprint $table) { + $table->dropForeign(['addon_id']); + $table->dropColumn('addon_id'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->dropForeign(['service_id']); + $table->dropColumn('service_id'); + }); + } +}; diff --git a/database/migrations/2026_09_26_160000_add_media_dashboard_queue_permissions.php b/database/migrations/2026_09_26_160000_add_media_dashboard_queue_permissions.php new file mode 100644 index 0000000..3f906f5 --- /dev/null +++ b/database/migrations/2026_09_26_160000_add_media_dashboard_queue_permissions.php @@ -0,0 +1,59 @@ +forgetCachedPermissions(); + + foreach (self::NEW_PERMISSIONS as $name) { + Permission::firstOrCreate(['name' => $name, 'guard_name' => 'admin']); + } + + $fullAdmin = Role::where('name', 'Full Administrator')->where('guard_name', 'admin')->first(); + if ($fullAdmin) { + $fullAdmin->givePermissionTo(self::NEW_PERMISSIONS); + } + + $administrator = Role::where('name', 'Administrator')->where('guard_name', 'admin')->first(); + if ($administrator) { + $administrator->givePermissionTo(self::GRANTED_TO_ADMINISTRATOR); + } + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } + + public function down(): void + { + Permission::where('guard_name', 'admin') + ->whereIn('name', self::NEW_PERMISSIONS) + ->delete(); + + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 239ecfb..9865965 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,32 +1,28 @@ - - - - tests/Unit - - - tests/Feature - - - - - app - - - - - - - - - - - - - - + + + + tests/Unit + + + tests/Feature + + + + + + + + + + + + + + + + + app + + diff --git a/routes/admin-media.php b/routes/admin-media.php new file mode 100644 index 0000000..0788e2a --- /dev/null +++ b/routes/admin-media.php @@ -0,0 +1,13 @@ +as('admin.transactions.') + ->middleware('signed') + ->group(function () { + Route::get('{transaction:uuid}/proof-image/{index?}', [AdminTransactionController::class, 'proofImage']) + ->whereNumber('index') + ->name('proof-image'); + }); diff --git a/routes/api.php b/routes/api.php index 6b721bc..8458d0c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,6 +26,7 @@ use App\Http\Controllers\Api\Admin\VCenter\VCenterController; use App\Http\Controllers\Api\Admin\DnsManager\ServerController as DnsServerController; use App\Http\Controllers\Api\Admin\DnsManager\DomainController as DnsDomainController; use App\Http\Controllers\Api\Admin\DnsManager\RecordController as DnsRecordController; +use App\Http\Controllers\Api\Admin\DnsManager\RegistrarController; use App\Http\Controllers\Api\Admin\Hosting\HostingAccountController; use App\Http\Controllers\Api\Admin\OsManager\OsGroupController; use App\Http\Controllers\Api\Admin\OsManager\OsController; @@ -44,11 +45,7 @@ use App\Http\Controllers\Api\Admin\Docs\PostController as AdminPostController; use App\Http\Controllers\Api\Admin\Media\MediaController as AdminMediaController; use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\Admin\InfraManagement\InfraManagementController; -use App\Http\Services\Media; -use App\Http\Services\vCenter as ServiceVCenter; -use App\Models\ModuleVmware; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; /* @@ -66,7 +63,7 @@ Route::prefix('v1') /* Api for Admin */ ->group(function () { Route::prefix('administrator')->group(function () { - Route::post('login', [AuthController::class, 'login'])->middleware('throttle:5,1'); + Route::post('login', [AuthController::class, 'login'])->middleware('throttle:30,1'); Route::middleware('api.auth:admin')->group(function () { Route::post('logout', [AuthController::class, 'logout']); @@ -265,35 +262,47 @@ Route::prefix('v1') ->controller(AdminPostCategoryController::class) ->as('post-categories.') ->group(function () { - Route::get('/', 'index')->name('index'); - Route::post('/', 'store')->name('store'); - Route::get('trashed', 'trashed')->name('trashed'); - Route::post('bulk-restore', 'bulkRestore')->name('bulk-restore'); - Route::post('bulk-force-delete', 'bulkForceDelete')->name('bulk-force-delete'); - Route::post('{id}/restore', 'restore')->name('restore'); - Route::delete('{id}/force-delete', 'forceDelete')->name('force-delete'); - Route::get('{id}', 'show')->name('show'); - Route::put('{id}', 'update')->name('update'); - Route::delete('{id}', 'destroy')->name('destroy'); + Route::get('/', 'index')->middleware('perm:post.view')->name('index'); + Route::post('/', 'store')->middleware('perm:post.create')->name('store'); + Route::get('trashed', 'trashed')->middleware('perm:post.view')->name('trashed'); + Route::post('bulk-restore', 'bulkRestore')->middleware('perm:post.edit')->name('bulk-restore'); + Route::post('bulk-force-delete', 'bulkForceDelete') + ->middleware('perm:post.destroy') + ->name('bulk-force-delete'); + Route::post('{id}/restore', 'restore')->middleware('perm:post.edit')->name('restore'); + Route::delete('{id}/force-delete', 'forceDelete') + ->middleware('perm:post.destroy') + ->name('force-delete'); + Route::get('{id}', 'show')->middleware('perm:post.view')->name('show'); + Route::put('{id}', 'update')->middleware('perm:post.edit')->name('update'); + Route::delete('{id}', 'destroy')->middleware('perm:post.destroy')->name('destroy'); }); Route::prefix('posts') ->controller(AdminPostController::class) ->as('posts.') ->group(function () { - Route::get('/', 'index')->name('index'); - Route::post('/', 'store')->name('store'); - Route::post('sync-wordpress', 'syncWordpress')->name('sync-wordpress'); - Route::post('bulk-delete', 'bulkDelete')->name('bulk-delete'); - Route::post('bulk-update-status', 'bulkUpdateStatus')->name('bulk-update-status'); - Route::get('trashed', 'trashed')->name('trashed'); - Route::post('bulk-restore', 'bulkRestore')->name('bulk-restore'); - Route::post('bulk-force-delete', 'bulkForceDelete')->name('bulk-force-delete'); - Route::post('{id}/restore', 'restore')->name('restore'); - Route::delete('{id}/force-delete', 'forceDelete')->name('force-delete'); - Route::get('{id}', 'show')->name('show'); - Route::put('{id}', 'update')->name('update'); - Route::delete('{id}', 'destroy')->name('destroy'); + Route::get('/', 'index')->middleware('perm:post.view')->name('index'); + Route::post('/', 'store')->middleware('perm:post.create')->name('store'); + Route::post('sync-wordpress', 'syncWordpress') + ->middleware('perm:post.create') + ->name('sync-wordpress'); + Route::post('bulk-delete', 'bulkDelete')->middleware('perm:post.destroy')->name('bulk-delete'); + Route::post('bulk-update-status', 'bulkUpdateStatus') + ->middleware('perm:post.edit') + ->name('bulk-update-status'); + Route::get('trashed', 'trashed')->middleware('perm:post.view')->name('trashed'); + Route::post('bulk-restore', 'bulkRestore')->middleware('perm:post.edit')->name('bulk-restore'); + Route::post('bulk-force-delete', 'bulkForceDelete') + ->middleware('perm:post.destroy') + ->name('bulk-force-delete'); + Route::post('{id}/restore', 'restore')->middleware('perm:post.edit')->name('restore'); + Route::delete('{id}/force-delete', 'forceDelete') + ->middleware('perm:post.destroy') + ->name('force-delete'); + Route::get('{id}', 'show')->middleware('perm:post.view')->name('show'); + Route::put('{id}', 'update')->middleware('perm:post.edit')->name('update'); + Route::delete('{id}', 'destroy')->middleware('perm:post.destroy')->name('destroy'); }); // Orders (module: order) @@ -422,6 +431,8 @@ Route::prefix('v1') Route::delete('{id}/dns/{recordId}', 'dnsDestroy')->middleware('perm:domain.manager'); }); + Route::get('registrars', [RegistrarController::class, 'index'])->middleware('perm:domain.manager'); + // DNS Manager — Servers (module: domain.manager) Route::prefix('dns-servers') ->controller(DnsServerController::class) @@ -538,12 +549,13 @@ Route::prefix('v1') Route::prefix('media') ->controller(AdminMediaController::class) ->group(function () { - Route::get('/', 'index'); - Route::post('bulk-destroy', 'bulkDestroy'); - Route::delete('{id}', 'destroy'); + Route::get('/', 'index')->middleware('perm:media.view'); + Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:media.destroy'); + Route::delete('{id}', 'destroy')->middleware('perm:media.destroy'); }); - Route::post('media/upload', [AdminSettingController::class, 'uploadMedia']); + Route::post('media/upload', [AdminSettingController::class, 'uploadMedia']) + ->middleware('perm:media.create'); // Provider (active vCenter chain + provider settings — module: module-vcenter) Route::prefix('provider')->group(function () { @@ -577,6 +589,7 @@ Route::prefix('v1') // Dashboard Stats Route::prefix('dashboard') ->controller(\App\Http\Controllers\Api\Admin\Dashboard\DashboardController::class) + ->middleware('perm:dashboard.view') ->group(function () { Route::get('stats', 'stats'); Route::get('monthly-stats', 'monthlyStats'); @@ -600,6 +613,7 @@ Route::prefix('v1') // Queue Monitor (Horizon) Route::prefix('queue') ->controller(QueueMonitorController::class) + ->middleware('perm:queue.manager') ->group(function () { Route::get('jobs/recent', 'recent'); Route::get('jobs/pending', 'pending'); @@ -755,85 +769,6 @@ Route::prefix('v1') // Public branding settings — không cần token, dùng cho trang đăng nhập. Route::get('settings/public', [AdminSettingController::class, 'public']); - Route::prefix('system') - ->middleware(['api.auth']) - ->group(function () { - Route::post('get-all-image', function (Media $media) { - $files = $media->getAllMedia(); - return response()->json(['data' => $files]); - })->name('api.GetAllImage'); - - Route::get('get-ticket-console', function (Request $request, ServiceVCenter $serviceVCenter) { - $product_id = $request->get('product'); - if (!$product_id) { - return response()->json(['error' => true, 'message' => 'Please choose product']); - } - $product = ModuleVmware::with('ipAddress.host.datacenter.vCenter')->find($product_id); - if (!$product) { - return response()->json(['error' => true, 'message' => "Can't find product"]); - } - - $vcenter = null; - if ( - $product->ipAddress && - $product->ipAddress->host && - $product->ipAddress->host->datacenter && - $product->ipAddress->host->datacenter->vCenter - ) { - $vcenter = $product->ipAddress->host->datacenter->vCenter; - } - - if (!$vcenter) { - return response()->json(['error' => true, 'message' => "Can't find vCenter information"]); - } - - // Đăng nhập vCenter trước khi lấy ticket - if (!$serviceVCenter->loginServer($vcenter)) { - return response()->json([ - 'error' => true, - 'message' => 'Không thể đăng nhập vào vCenter', - ]); - } - - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - - // Nếu login hết hạn, thử login lại 1 lần nữa - if ($ConsoleTicket === 'Login_Again') { - if ($serviceVCenter->loginServer($vcenter)) { - $ConsoleTicket = $serviceVCenter->GetConsoleTickets($product->vmware_id); - } else { - return response()->json([ - 'error' => true, - 'message' => 'Không thể đăng nhập lại vào vCenter', - ]); - } - } - - // Nếu vẫn lỗi, trả về lỗi rõ ràng - if (is_string($ConsoleTicket)) { - return response()->json([ - 'error' => true, - 'message' => "Failed to get console ticket: $ConsoleTicket", - ]); - } - - if ($ConsoleTicket['error']) { - return response()->json([ - 'error' => true, - 'message' => $ConsoleTicket['data'], - ]); - } - - return response() - ->json([ - 'error' => false, - 'data' => $ConsoleTicket['data'], - 'vmware-api-session-id' => $serviceVCenter->token, - ]) - ->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false); - })->name('api.GetConsoleTicket'); - }); - Route::get('pdf-invoice', function () { // return Pdf::loadView('pdf.invoice-pdf')->setPaper('a4', 'portrait')->stream('hoa-don-mau.pdf'); }); diff --git a/routes/client.php b/routes/client.php index 4fa28f8..4f2e5cb 100644 --- a/routes/client.php +++ b/routes/client.php @@ -282,6 +282,7 @@ Route::prefix('v1/system') Route::get('vm-power-state', 'powerState')->name('vm.power-state'); Route::post('vm-power-on', 'powerOn')->name('vm.power-on'); Route::post('vm-power-off', 'powerOff')->name('vm.power-off'); + Route::get('vm-ip-options', 'ipOptions')->name('vm.ip-options'); Route::post('vm-change-ip', 'changeIp')->name('vm.change-ip'); Route::post('vm-change-port', 'changePort')->name('vm.change-port'); Route::post('vm-change-password', 'changePassword')->name('vm.change-password'); diff --git a/storage/oauth-private.key b/storage/oauth-private.key deleted file mode 100755 index adee2f9..0000000 --- a/storage/oauth-private.key +++ /dev/null @@ -1,52 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQCyB8wOue0wtHDi -4l6gBDw+oIZV2JvgfEdn7f6E5Urli/Ea9DeALhKluR/W+ZF5znCXsdxS3dEMtjDJ -WjnqXAMN2s8EP8KH1brsQGjbaQcMP7ei5ASzHJ81gJ3tCXFzYhNpXTBr3xarBfwJ -1yK2L1Yq8F1y0pdNz7qaQQEOLGI02ant1GT2LFinCKRNniKVZW6GKCbd1F5FopjW -wd6KcrA4Ls9BEhKaqgWz0nJscYnxGrmlrK9R9yiE6QQGnSBIuw1qDMwD/uP9zTYb -xNBNq2Q0fvpZ7xrbSYkk8gN1CGclT/Tx6uWguhh8bwsnnsDrVRdelkTyzi+Bb1+L -L53PJcWuLKlJjaAvBZ0HXLQRDTYTmdyz5ADchmJUstP8s4Bt1qQhP4DjVOOABnt/ -8YrViS+dJKeyBsD0rZ8e/c64gUGqKfQwNK6SKWZEqhBBHTk2QZIHNACNXynONWOp -WXw3inm99QVRW7M5UdjEy3bIqFCwbub7NAUBcElIaRBjXUt7nW15dhPOymVyTqiW -E6wXb1brSRSxsXvrAkco2GWiURLIk4cCSFsWrbPhySO2DAx5G/KsBNsNqIvSEmia -OchA4Z1YRZBnRjh7xW2a5iDC37z2/IXugKJShN8ulyUBRLRBOnZ9sweBtURWcL5Y -+jkFFnecvgEAzMaBlwouDcYLrXr6zwIDAQABAoICAQCez7HKWTUrQ2uIGWNL3ztW -qp/rKIU0peiU34syRdCgIEv+PWjdITxlNVUf0hVh0TRDPdhK27DGZCf1FlWfqGDZ -FR2irrX7sj9CzNo9D93HoXdRCZLfrGKhuZAniUr5IL4sOYMeVy3Ddl008gL2/hch -yrWx3gY21V2iiOZGHHWmq5yYmeVObJTQvDbFPpGfL5J+fobdT7qg58DropnAazqj -g65CX7rU2WKo3MxRzJ6+w5ZkIj1rIs20cqCyvau09ZeryKqdzdzX5R2le9ZN0txa -sALoAxG0GE8M9A79cXeBo2ff/ijFTfzV08DhoQ57X7avG+F5r1BVaGKUqO9fgFRr -5Dyrw2/7bU8bPtW8bcZNr4I+jQCbKaynBvl6L8XmwtuYC+eHxUiL9sTjy1VJ6qEk -0M5JfbKTIlx35bazzjVz1vTsApJzyP90+V8+M8tMSx7udapJlNbbbWKPLx5/YEcp -JBOood74RF+1P335tpznTHIRAEIddC0WMb3dlEHejmS1hFEIHT2Hdn7JYxrv50iv -9PLtbvoLFqcxuwbYb0wsQUI5+eHqdURC+r2M+5hZbNcqlZgNt1UTjJLDmcv3BCRH -kO6kcweLSi4oc+Kb+0/Hwwx89eGUKY1eRYxKilMNCaNAKYu+FwRI7kdbQJ+kA8Xd -0WJaZe6jvTyMrUXlG+ZQMQKCAQEA3FdZr648eFHJncFcRjcGDOAj0K4rwqsbnOIZ -rsxmZyQYAttQqh4CYILER0lfFRxJdjydLA/UKUdkiOZaT7hiqID+2gk+n0OvemO+ -LEcvdZpqJ/0MU1WSAq0FkShoIGUC9N6wazxTePva8P8Y4legNLUBhIzMYMVZCgLW -RH7Xtlhi1E4261eIrroPV6x3RQYcv9QXtjQkzU2FOd41BUYm/nJ0M16MODT04aYQ -5mleUhH7j16hp2Q1FW0hIcG94doOkf0lMgzZMKSjEfZCWY3vAsbRJDFRtUMvrKML -SvLm034eSFmErbx6FtX690IJqebq8+5X/1LrL862bGp2nu0r0wKCAQEAzteHYrk+ -2mv3fam2gTgw/Fe2x5SMcdA2+gpMbA3P2uVTO+U8J1mh+pGHRBmFhGm3bKakYubu -dACYebsKH4ZPaYUvllQPlwwlNH1etttg4L6Dp8bwMhKSeqi2W/oaBNaR6YajOieP -YkyzNUNn528RFGYzXy6C7Nc85FaIeOJG+yHbkssnD4ETLjXVQev6OJacJ2ow06dL -qhVkGQfskqx7sJGp9Sk4xz84S8Y7LlqljRZH7BTqik69RWY/Z1IY0Zg8VylnrXVt -V/Shv3d1KCbob1zwfj6IGM8vn4DQLJQoI1dMtxwO75OPuf0QZByGLUgchjUvBhM3 -xEht5HLtzz8DlQKCAQEAjn+vcP6t/OJoEa5Ljt1m/Ey43OzHQE6IvR08nt9yGqIt -JSvRLzPZNbFcXv4EkBKLL/xjaWuTOl8BlcfkAEqvA2rpZvkzlqyff/ehteETwGrs -3I+6uOpOU4yyXP5U2R56mwKp9bA+C7BWGCXiYf9pjKzDHoYwZyU6QE81Y3bk6vie -cc9jlTA3JUkPQxQ2zlz1mtn/u8ZdRqkr0BD1GUQVU9mRbOdop7EX7hcINvtOFKWs -mzwIL8mOyxyYoMw7kSUE50VgGM5WaC8RhzxMDL7OJG2QNGfw/9lDq9KGKyKMRZr6 -2q+KLxIFo18fv2jl9wtwR/GM14M4tuJo+BSiQoYAIQKCAQB4s8VJkxhelcqjBjp+ -RbAwUW9UIBelAwBSAVh/Zb/52QEtXqQvwlqHwgzRbKNBlf934/bh0CkugHp57vAF -XXdR+GrAquU+G9RKQS7YPkTWYdaqPalFTxkzguiH2/b7A8u6Htt5wEQn9OJPHrqv -j1zELq+jx5yXYTMjfGu/Pyx0uFlN3Ec7APOUb8223mHyS9FsHsfHe0ToR/tVsA4Z -uZWkgBxR+HAttXLApTk736HkJzBpT/teInzI1tYBESNyjAgKRRs/WdspDJSnCqrP -d4UEF5Qv14XMFHuR9LWGjh6uIO+fmzwMCmxA+LBr+0lBe2dJ2hdK0EQmwq1+NqMX -4YnRAoIBAD6cNXLTFsJluGFJx7Q4b+NWnu95DbqtBshSSLU5zvVfzL9hI/cWy4wT -3WSJmMOXGcl5piZ65oRPa4HtVzXKhfIr0TZMXee50OjD2TVT+25mto2ZfQ1vfWBN -VF/HXcugOBTHpNOv8zOuo8gvVffDt7itjr9HV88h6LbdMTwyM68GwcRFiz1Efkd4 -TOvXQ1FdRa9A4xeS5Rbe1W9qMgFlmsA3ZP7FCk9Is18Iez5BldjYCyKbBXyy9cel -67ReVy50HEIM1Zoo7NyE6JfK3D7DejTQMJVYuFmih+7WFX+3CorQIphT9grc+D6u -s1ZzwPxkdw9vI1pivrM3BaAyYorHecM= ------END PRIVATE KEY----- \ No newline at end of file diff --git a/storage/oauth-public.key b/storage/oauth-public.key deleted file mode 100755 index 885a8b2..0000000 --- a/storage/oauth-public.key +++ /dev/null @@ -1,14 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsgfMDrntMLRw4uJeoAQ8 -PqCGVdib4HxHZ+3+hOVK5YvxGvQ3gC4Spbkf1vmRec5wl7HcUt3RDLYwyVo56lwD -DdrPBD/Ch9W67EBo22kHDD+3ouQEsxyfNYCd7Qlxc2ITaV0wa98WqwX8Cdciti9W -KvBdctKXTc+6mkEBDixiNNmp7dRk9ixYpwikTZ4ilWVuhigm3dReRaKY1sHeinKw -OC7PQRISmqoFs9JybHGJ8Rq5payvUfcohOkEBp0gSLsNagzMA/7j/c02G8TQTatk -NH76We8a20mJJPIDdQhnJU/08erloLoYfG8LJ57A61UXXpZE8s4vgW9fiy+dzyXF -riypSY2gLwWdB1y0EQ02E5ncs+QA3IZiVLLT/LOAbdakIT+A41TjgAZ7f/GK1Ykv -nSSnsgbA9K2fHv3OuIFBqin0MDSukilmRKoQQR05NkGSBzQAjV8pzjVjqVl8N4p5 -vfUFUVuzOVHYxMt2yKhQsG7m+zQFAXBJSGkQY11Le51teXYTzsplck6olhOsF29W -60kUsbF76wJHKNhlolESyJOHAkhbFq2z4ckjtgwMeRvyrATbDaiL0hJomjnIQOGd -WEWQZ0Y4e8VtmuYgwt+89vyF7oCiUoTfLpclAUS0QTp2fbMHgbVEVnC+WPo5BRZ3 -nL4BAMzGgZcKLg3GC616+s8CAwEAAQ== ------END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/Feature/Api/Admin/Employee/EmployeeRoleGuardTest.php b/tests/Feature/Api/Admin/Employee/EmployeeRoleGuardTest.php new file mode 100644 index 0000000..5fbb943 --- /dev/null +++ b/tests/Feature/Api/Admin/Employee/EmployeeRoleGuardTest.php @@ -0,0 +1,116 @@ + 'administrator.edit', 'guard_name' => 'admin']); + Permission::firstOrCreate(['name' => 'administrator.destroy', 'guard_name' => 'admin']); + + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $administrator = Role::firstOrCreate(['name' => 'Administrator', 'guard_name' => 'admin']); + $sale = Role::firstOrCreate(['name' => 'Sale', 'guard_name' => 'admin']); + + $administrator->givePermissionTo(['administrator.edit', 'administrator.destroy']); + $sale->givePermissionTo(['administrator.edit', 'administrator.destroy']); + } + + private function makeEmployee(string $role): User + { + $user = User::factory()->create([ + 'username' => 'user-' . uniqid(), + 'email' => uniqid() . '@example.com', + ]); + $user->assignRole($role); + + return $user; + } + + private function payload(array $overrides = []): array + { + return array_merge([ + 'fullname' => 'Test User', + 'username' => 'test-' . uniqid(), + 'email' => uniqid() . '@example.com', + 'role' => 'Sale', + ], $overrides); + } + + public function test_non_full_administrator_cannot_edit_a_full_administrator(): void + { + $fullAdmin = $this->makeEmployee('Full Administrator'); + $caller = $this->makeEmployee('Administrator'); + + $response = $this->actingAs($caller, 'admin')->putJson( + "/api/v1/administrator/employees/{$fullAdmin->id}", + $this->payload(['role' => 'Sale']), + ); + + $response->assertStatus(403); + } + + public function test_non_full_administrator_cannot_delete_a_full_administrator(): void + { + $fullAdmin = $this->makeEmployee('Full Administrator'); + $caller = $this->makeEmployee('Administrator'); + + $response = $this->actingAs($caller, 'admin')->deleteJson( + "/api/v1/administrator/employees/{$fullAdmin->id}", + ); + + $response->assertStatus(403); + } + + public function test_cannot_change_own_role(): void + { + $caller = $this->makeEmployee('Sale'); + + $response = $this->actingAs($caller, 'admin')->putJson( + "/api/v1/administrator/employees/{$caller->id}", + $this->payload(['username' => $caller->username, 'email' => $caller->email, 'role' => 'Administrator']), + ); + + $response->assertStatus(403); + } + + public function test_non_full_administrator_cannot_assign_administrator_role(): void + { + $caller = $this->makeEmployee('Administrator'); + $target = $this->makeEmployee('Sale'); + + $response = $this->actingAs($caller, 'admin')->putJson( + "/api/v1/administrator/employees/{$target->id}", + $this->payload(['username' => $target->username, 'email' => $target->email, 'role' => 'Administrator']), + ); + + $response->assertStatus(403); + } + + public function test_full_administrator_can_assign_administrator_role(): void + { + $caller = $this->makeEmployee('Full Administrator'); + $target = $this->makeEmployee('Sale'); + + $response = $this->actingAs($caller, 'admin')->putJson( + "/api/v1/administrator/employees/{$target->id}", + $this->payload(['username' => $target->username, 'email' => $target->email, 'role' => 'Administrator']), + ); + + $response->assertOk(); + $this->assertTrue($target->fresh()->hasRole('Administrator')); + } +} diff --git a/tests/Feature/Api/Admin/LoginThrottleTest.php b/tests/Feature/Api/Admin/LoginThrottleTest.php index 50af3d9..7d4d3df 100644 --- a/tests/Feature/Api/Admin/LoginThrottleTest.php +++ b/tests/Feature/Api/Admin/LoginThrottleTest.php @@ -24,14 +24,14 @@ class LoginThrottleTest extends TestCase { for ($i = 0; $i < 5; $i++) { $response = $this->postJson('/api/v1/administrator/login', [ - 'email' => 'nobody@example.com', + 'username' => 'nobody', 'password' => 'wrong-password', ]); $this->assertNotSame(429, $response->status()); } $response = $this->postJson('/api/v1/administrator/login', [ - 'email' => 'nobody@example.com', + 'username' => 'nobody', 'password' => 'wrong-password', ]); diff --git a/tests/Feature/Api/Admin/PermissionGatingTest.php b/tests/Feature/Api/Admin/PermissionGatingTest.php index 313f260..c9b9047 100644 --- a/tests/Feature/Api/Admin/PermissionGatingTest.php +++ b/tests/Feature/Api/Admin/PermissionGatingTest.php @@ -62,9 +62,43 @@ class PermissionGatingTest extends TestCase 'tickets index' => ['GET', '/api/v1/administrator/tickets', 'support.view'], 'activity-logs index' => ['GET', '/api/v1/administrator/activity-logs', 'system-log.manager'], 'email-logs index' => ['GET', '/api/v1/administrator/email-logs', 'system-log.manager'], + 'post-categories index' => ['GET', '/api/v1/administrator/post-categories', 'post.view'], + 'posts index' => ['GET', '/api/v1/administrator/posts', 'post.view'], + 'media index' => ['GET', '/api/v1/administrator/media', 'media.view'], + 'dashboard stats' => ['GET', '/api/v1/administrator/dashboard/stats', 'dashboard.view'], + 'queue jobs recent' => ['GET', '/api/v1/administrator/queue/jobs/recent', 'queue.manager'], ]; } + public function test_force_delete_post_requires_post_destroy_permission(): void + { + Permission::firstOrCreate(['name' => 'post.destroy', 'guard_name' => 'admin']); + + $unauthorized = $this->makeAdminWithPermissions(['post.view']); + $this->actingAs($unauthorized, 'admin') + ->deleteJson('/api/v1/administrator/posts/999/force-delete') + ->assertStatus(403); + + $authorized = $this->makeAdminWithPermissions(['post.destroy']); + $response = $this->actingAs($authorized, 'admin') + ->deleteJson('/api/v1/administrator/posts/999/force-delete'); + $this->assertNotSame(403, $response->status()); + } + + public function test_dashboard_requires_dashboard_view_permission(): void + { + Permission::firstOrCreate(['name' => 'dashboard.view', 'guard_name' => 'admin']); + + $unauthorized = $this->makeAdminWithPermissions(['post.view']); + $this->actingAs($unauthorized, 'admin') + ->getJson('/api/v1/administrator/dashboard/stats') + ->assertStatus(403); + + $authorized = $this->makeAdminWithPermissions(['dashboard.view']); + $response = $this->actingAs($authorized, 'admin')->getJson('/api/v1/administrator/dashboard/stats'); + $this->assertNotSame(403, $response->status()); + } + public function test_full_administrator_bypasses_all_permission_gates(): void { Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); diff --git a/tests/Feature/Api/Client/ClientAuthSecurityTest.php b/tests/Feature/Api/Client/ClientAuthSecurityTest.php index d8f5ca4..b477257 100644 --- a/tests/Feature/Api/Client/ClientAuthSecurityTest.php +++ b/tests/Feature/Api/Client/ClientAuthSecurityTest.php @@ -7,6 +7,7 @@ namespace Tests\Feature\Api\Client; use App\Mail\Customer\PasswordResetLink; use App\Models\Customer; use App\Models\RefreshToken; +use App\Services\Api\Client\ClientAuthService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; @@ -138,6 +139,76 @@ class ClientAuthSecurityTest extends TestCase ->getJson('/v1/banks')->assertStatus(429); } + public function test_logout_revokes_refresh_token_so_it_cannot_be_used_again(): void + { + $customer = Customer::factory()->create(['status' => 1]); + $jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer); + $rawRefreshToken = 'raw-refresh-token'; + RefreshToken::create([ + 'customer_id' => $customer->id, + 'token' => hash('sha256', $rawRefreshToken), + 'expires_at' => now()->addDays(30), + ]); + + $this->api()->withHeader('Authorization', "Bearer {$jwt}") + ->postJson('/v1/client/logout', ['refresh_token' => $rawRefreshToken]) + ->assertOk(); + + $this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count()); + + $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken]) + ->assertStatus(401); + } + + public function test_reused_rotated_refresh_token_revokes_all_tokens_of_customer(): void + { + $customer = Customer::factory()->create(['status' => 1]); + $rawRefreshToken = 'raw-refresh-token'; + RefreshToken::create([ + 'customer_id' => $customer->id, + 'token' => hash('sha256', $rawRefreshToken), + 'expires_at' => now()->addDays(30), + ]); + + // Rotation hợp lệ lần đầu: đánh dấu token cũ đã dùng, cấp token mới. + $first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk(); + $newRawRefreshToken = $first->json('data.refresh_token'); + $this->assertNotSame($rawRefreshToken, $newRawRefreshToken); + // Token cũ vẫn còn nhưng đã đánh dấu revoked_at, cộng token mới vừa cấp. + $this->assertSame(2, RefreshToken::where('customer_id', $customer->id)->count()); + + $this->travel(ClientAuthService::REFRESH_TOKEN_REUSE_GRACE_SECONDS + 1)->seconds(); + + // Dùng lại token cũ (đã rotate): coi như bị lộ, thu hồi toàn bộ token của khách. + $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken]) + ->assertStatus(401); + + $this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count()); + + // Token mới (hợp lệ, chưa dùng) cũng đã bị thu hồi theo. + $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $newRawRefreshToken]) + ->assertStatus(401); + } + + public function test_concurrent_refresh_within_grace_window_does_not_revoke_sessions(): void + { + $customer = Customer::factory()->create(['status' => 1]); + $rawRefreshToken = 'raw-refresh-token'; + RefreshToken::create([ + 'customer_id' => $customer->id, + 'token' => hash('sha256', $rawRefreshToken), + 'expires_at' => now()->addDays(30), + ]); + + $first = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk(); + $second = $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $rawRefreshToken])->assertOk(); + + foreach ([$first, $second] as $response) { + $this->api()->postJson('/v1/client/refresh', ['refresh_token' => $response->json('data.refresh_token')]) + ->assertOk(); + } + } + public function test_legacy_cccd_path_is_not_exposed(): void { $customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']); diff --git a/tests/Feature/Api/Client/SecurityHardeningTest.php b/tests/Feature/Api/Client/SecurityHardeningTest.php new file mode 100644 index 0000000..126930c --- /dev/null +++ b/tests/Feature/Api/Client/SecurityHardeningTest.php @@ -0,0 +1,57 @@ +withHeaders(['x-access-token' => $this->buildClientAccessToken()])->postJson('/v1/client/register', [ + 'fullname' => 'Nguyen Van A', + 'phone' => '0912345678', + 'email' => 'short-pass@example.com', + 'password' => 'abc12345', + 'password_confirmation' => 'abc12345', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('password'); + } + + public function test_register_accepts_password_with_at_least_ten_characters(): void + { + $response = $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->postJson('/v1/client/register', [ + 'fullname' => 'Nguyen Van B', + 'phone' => '0912345679', + 'email' => 'long-pass@example.com', + 'password' => 'abcdefghij', + 'password_confirmation' => 'abcdefghij', + ]); + + $response->assertJsonMissingValidationErrors('password'); + } + + public function test_cccd_upload_rejects_svg(): void + { + $customer = \App\Models\Customer::factory()->create(); + + $response = $this->actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->post('/v1/client/update-cccd', [ + 'cmnd' => '012345678901', + 'cmnd_before' => UploadedFile::fake()->create('front.svg', 10, 'image/svg+xml'), + 'cmnd_after' => UploadedFile::fake()->image('back.jpg'), + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('cmnd_before'); + } +} diff --git a/tests/Feature/Api/Service/OrderErrorMessageTest.php b/tests/Feature/Api/Service/OrderErrorMessageTest.php new file mode 100644 index 0000000..474f073 --- /dev/null +++ b/tests/Feature/Api/Service/OrderErrorMessageTest.php @@ -0,0 +1,72 @@ +actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + public function test_pay_order_hides_unknown_exception_message(): void + { + $customer = Customer::factory()->create(); + $order = Order::create([ + 'order_num' => 'ORD-TEST-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 100000, + 'discount_amount' => 0, + 'total' => 100000, + 'status' => OrderStatus::PENDING->value, + ]); + + $mock = \Mockery::mock(OrderService::class); + $mock->shouldReceive('pay') + ->once() + ->andThrow(new \RuntimeException('leaking internal DB connection string secret')); + $this->app->instance(OrderService::class, $mock); + + $response = $this->actingAsCustomer($customer)->postJson("/v1/orders/{$order->order_num}/pay", [ + 'payment_method' => 'balance', + ]); + + $response->assertStatus(500); + $response->assertJsonPath('success', false); + $this->assertStringNotContainsString('leaking internal', (string) $response->getContent()); + $this->assertSame('Có lỗi xảy ra, vui lòng thử lại', $response->json('message')); + } + + public function test_pay_order_keeps_business_message_for_invalid_argument(): void + { + $customer = Customer::factory()->create(['total' => 0]); + $order = Order::create([ + 'order_num' => 'ORD-TEST-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 100000, + 'discount_amount' => 0, + 'total' => 100000, + 'status' => OrderStatus::PENDING->value, + ]); + + $response = $this->actingAsCustomer($customer)->postJson("/v1/orders/{$order->order_num}/pay", [ + 'payment_method' => 'balance', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('success', false); + $this->assertStringContainsString('Số dư không đủ', (string) $response->json('message')); + } +} diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php index d4c6588..afa0b0a 100644 --- a/tests/Feature/Api/Service/RenewServiceTest.php +++ b/tests/Feature/Api/Service/RenewServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Api\Service; use App\Enums\Invoice\InvoiceStatus; +use App\Models\Addon; use App\Enums\Order\OrderStatus; use App\Models\Customer; use App\Models\Invoice; @@ -13,6 +14,7 @@ use App\Models\OrderItem; use App\Models\Product; use App\Models\ProductPricings; use App\Models\Service; +use App\Models\ServiceAddon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -70,6 +72,137 @@ class RenewServiceTest extends TestCase ]); } + private function createServiceWithCycle(string $billingCycle, int $cycleMonths, int $oldPrice): Service + { + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $this->customer->id, + 'sub_total' => $oldPrice, + 'total' => $oldPrice, + 'status' => OrderStatus::ACTIVE->value, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $this->product->id, + 'billing_cycle' => $billingCycle, + 'months' => $cycleMonths, + 'quantity' => 1, + 'price' => $oldPrice, + ]); + + return Service::create([ + 'customer_id' => $this->customer->id, + 'product_id' => $this->product->id, + 'order_item_id' => $orderItem->id, + 'status' => 'active', + 'server_name' => 'test-server', + ]); + } + + public function test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price(): void + { + $service = $this->createServiceWithCycle('annually', 12, 1_800_000); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'annually', + 'price' => 1_800_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 12], + ); + + $response->assertOk(); + $response->assertJsonPath('data.total', 1_800_000); + $this->assertDatabaseHas('invoices', [ + 'id' => $response->json('data.invoice_id'), + 'total' => 1_800_000, + ]); + } + + public function test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected(): void + { + $service = $this->createServiceWithCycle('annually', 12, 1_800_000); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'annually', + 'price' => 1_800_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + + $response->assertStatus(422); + $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); + } + + public function test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price(): void + { + $service = $this->createServiceWithOldPrice(90_000); + + $addon = Addon::create([ + 'type' => $this->product->product_type, + 'code' => 'ram', + 'name' => 'RAM', + 'price' => 50_000, + 'min' => 1, + 'max' => 64, + 'step' => 1, + 'status' => true, + ]); + + $service->orderItem->update(['addons_meta' => [[ + 'addon_id' => $addon->id, + 'code' => 'ram', + 'name' => 'RAM', + 'qty' => 2, + 'price' => 40_000, + 'subtotal' => 80_000, + ]]]); + + ServiceAddon::create([ + 'service_id' => $service->id, + 'addon_type' => 'ram', + 'addon_value' => '6144', + 'price' => 50_000, + ]); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + + $response->assertOk(); + $response->assertJsonPath('data.total', 200_000); + $invoiceId = $response->json('data.invoice_id'); + $this->assertDatabaseHas('invoice_items', [ + 'invoice_id' => $invoiceId, + 'type' => 'renewal', + 'unit_price' => 100_000, + ]); + $this->assertDatabaseHas('invoice_items', [ + 'invoice_id' => $invoiceId, + 'type' => 'addon', + 'quantity' => 2, + 'total_price' => 100_000, + ]); + } + public function test_renew_uses_current_catalog_price_not_the_original_order_price(): void { $service = $this->createServiceWithOldPrice(90_000); diff --git a/tests/Feature/Api/Service/ServiceOwnershipTest.php b/tests/Feature/Api/Service/ServiceOwnershipTest.php new file mode 100644 index 0000000..b6291e0 --- /dev/null +++ b/tests/Feature/Api/Service/ServiceOwnershipTest.php @@ -0,0 +1,96 @@ +actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + public function test_client_cannot_fetch_another_customers_service_by_uuid(): void + { + $owner = Customer::factory()->create(); + $attacker = Customer::factory()->create(); + $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); + + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $owner->id, + 'sub_total' => 100000, + 'total' => 100000, + 'status' => OrderStatus::ACTIVE->value, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'quantity' => 1, + 'price' => 100000, + ]); + + $service = Service::create([ + 'customer_id' => $owner->id, + 'product_id' => $product->id, + 'order_item_id' => $orderItem->id, + 'status' => 'active', + 'server_name' => 'owner-server', + ]); + + $response = $this->actingAsCustomer($attacker)->getJson("/v1/client/service/{$service->uuid}"); + + $response->assertStatus(404); + } + + public function test_client_can_fetch_own_service_by_uuid(): void + { + $owner = Customer::factory()->create(); + $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); + + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $owner->id, + 'sub_total' => 100000, + 'total' => 100000, + 'status' => OrderStatus::ACTIVE->value, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'quantity' => 1, + 'price' => 100000, + ]); + + $service = Service::create([ + 'customer_id' => $owner->id, + 'product_id' => $product->id, + 'order_item_id' => $orderItem->id, + 'status' => 'active', + 'server_name' => 'owner-server', + ]); + + $response = $this->actingAsCustomer($owner)->getJson("/v1/client/service/{$service->uuid}"); + + $this->assertNotSame(404, $response->status()); + } +} diff --git a/tests/Feature/Api/Service/StoreOrderAddonTest.php b/tests/Feature/Api/Service/StoreOrderAddonTest.php new file mode 100644 index 0000000..edf18ed --- /dev/null +++ b/tests/Feature/Api/Service/StoreOrderAddonTest.php @@ -0,0 +1,139 @@ +customer = Customer::factory()->create(['total' => 10_000_000]); + + $this->product = Product::factory()->create([ + 'product_type' => 'vps', + 'is_active' => true, + ]); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + } + + private function actingAsCustomer(): static + { + return $this->actingAs($this->customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + private function storeOrder(array $addons): \Illuminate\Testing\TestResponse + { + return $this->actingAsCustomer()->postJson('/v1/orders', [ + 'product_id' => $this->product->uuid, + 'billing_cycle' => 'monthly', + 'addons' => $addons, + ]); + } + + public function test_addon_belonging_to_another_product_type_is_rejected(): void + { + $hostingAddon = Addon::create([ + 'type' => 'hosting', + 'code' => 'storage', + 'name' => 'Storage', + 'min' => 1, + 'max' => 100, + 'step' => 1, + 'price' => 10_000, + 'status' => true, + ]); + + $response = $this->storeOrder([ + ['addon_id' => $hostingAddon->id, 'qty' => 1], + ]); + + $response->assertStatus(422)->assertJsonPath('success', false); + $this->assertSame(10_000_000, $this->customer->fresh()->total); + } + + public function test_inactive_addon_is_rejected(): void + { + $inactiveAddon = Addon::create([ + 'type' => 'vps', + 'code' => 'cpu-inactive', + 'name' => 'CPU', + 'min' => 1, + 'max' => 32, + 'step' => 1, + 'price' => 25_000, + 'status' => false, + ]); + + $response = $this->storeOrder([ + ['addon_id' => $inactiveAddon->id, 'qty' => 1], + ]); + + $response->assertStatus(422)->assertJsonPath('success', false); + } + + public function test_qty_above_max_is_rejected(): void + { + $cpuAddon = Addon::create([ + 'type' => 'vps', + 'code' => 'cpu', + 'name' => 'CPU', + 'min' => 1, + 'max' => 32, + 'step' => 1, + 'price' => 25_000, + 'status' => true, + ]); + + $response = $this->storeOrder([ + ['addon_id' => $cpuAddon->id, 'qty' => 999], + ]); + + $response->assertStatus(422)->assertJsonPath('success', false); + } + + public function test_valid_addon_within_bounds_is_accepted(): void + { + $cpuAddon = Addon::create([ + 'type' => 'vps', + 'code' => 'cpu-ok', + 'name' => 'CPU', + 'min' => 1, + 'max' => 32, + 'step' => 1, + 'price' => 25_000, + 'status' => true, + ]); + + $response = $this->storeOrder([ + ['addon_id' => $cpuAddon->id, 'qty' => 4], + ]); + + $response->assertStatus(201)->assertJsonPath('success', true); + $this->assertDatabaseHas('invoices', [ + 'customer_id' => $this->customer->id, + 'total' => 100_000 + (25_000 * 4), + ]); + } +} diff --git a/tests/Feature/Api/Service/VmChangeIpTest.php b/tests/Feature/Api/Service/VmChangeIpTest.php new file mode 100644 index 0000000..7e9f4aa --- /dev/null +++ b/tests/Feature/Api/Service/VmChangeIpTest.php @@ -0,0 +1,155 @@ +create(); + $network = ModuleNetwork::create(['name' => 'test-net']); + + $order = Order::create(['order_num' => 'ORD-' . Str::random(10), 'customer_id' => $customer->id]); + $orderItem = OrderItem::create(['order_id' => $order->id, 'server_name' => 'vm-test.local']); + $service = Service::create([ + 'customer_id' => $customer->id, + 'order_item_id' => $orderItem->id, + 'server_name' => 'vm-test.local', + 'status' => $status, + ]); + + $currentIp = ModuleIpAddress::create([ + 'module_network_id' => $network->id, + 'ip' => '103.1.1.10', + 'status' => 1, + ]); + + $vm = ModuleVmware::create([ + 'name' => 'vm-test', + 'customer_id' => $customer->id, + 'vmware_id' => 'vm-9001', + 'order_id' => $order->id, + 'module_ip_address' => $currentIp->id, + 'ip' => '103.1.1.10', + 'status' => 1, + 'service_id' => $service->id, + ]); + + IpPool::create([ + 'uuid' => (string) Str::uuid(), + 'ip_address' => '103.1.1.10', + 'module_network_id' => $network->id, + 'status' => 'assigned', + 'service_id' => $service->id, + ]); + + $freePool = IpPool::create([ + 'uuid' => (string) Str::uuid(), + 'ip_address' => '103.1.1.20', + 'module_network_id' => $network->id, + 'status' => 'free', + 'gateway' => '103.1.1.1', + ]); + + $service->refresh(); + $service->update(['status' => $status]); + + $otherCustomer = Customer::factory()->create(); + $otherOrder = Order::create(['order_num' => 'ORD-' . Str::random(10), 'customer_id' => $otherCustomer->id]); + $otherService = Service::create([ + 'customer_id' => $otherCustomer->id, + 'status' => 'active', + ]); + $usedPool = IpPool::create([ + 'uuid' => (string) Str::uuid(), + 'ip_address' => '103.1.1.30', + 'module_network_id' => $network->id, + 'status' => 'assigned', + 'service_id' => $otherService->id, + ]); + + return compact('customer', 'network', 'service', 'vm', 'freePool', 'usedPool'); + } + + private function changeIpPayload(array $vars, string $ip): array + { + return [ + 'server_name' => $vars['service']->server_name, + 'ip_address' => $ip, + 'guest_user' => 'root', + 'guest_password' => 'secret123', + ]; + } + + public function test_change_ip_outside_pool_returns_422(): void + { + $vars = $this->makeActiveServiceWithVm(); + + $response = $this->actingAs($vars['customer'], 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/system/vm-change-ip', $this->changeIpPayload($vars, '103.1.1.99')); + + $response->assertStatus(422); + } + + public function test_change_ip_already_in_use_returns_422(): void + { + $vars = $this->makeActiveServiceWithVm(); + + $response = $this->actingAs($vars['customer'], 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/system/vm-change-ip', $this->changeIpPayload($vars, '103.1.1.30')); + + $response->assertStatus(422); + } + + public function test_change_ip_to_free_pool_ip_dispatches_job_with_pool_gateway(): void + { + Queue::fake(); + + $vars = $this->makeActiveServiceWithVm(); + + $response = $this->actingAs($vars['customer'], 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/system/vm-change-ip', $this->changeIpPayload($vars, '103.1.1.20')); + + $response->assertStatus(200)->assertJson(['success' => true]); + + Queue::assertPushed(ChangeIpVMJob::class, function (ChangeIpVMJob $job) use ($vars) { + return $job->ipAddress === '103.1.1.20' + && $job->gateway === '103.1.1.1' + && $job->newIpPoolId === $vars['freePool']->id; + }); + + $this->assertSame('assigned', $vars['freePool']->fresh()->status); + } + + public function test_power_on_for_suspended_service_returns_403(): void + { + $vars = $this->makeActiveServiceWithVm(status: 'suspended'); + + $response = $this->actingAs($vars['customer'], 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson('/v1/system/vm-power-on', ['product' => $vars['vm']->id]); + + $response->assertStatus(403); + } +} diff --git a/tests/Feature/Api/Service/VmUpgradeAddonsTest.php b/tests/Feature/Api/Service/VmUpgradeAddonsTest.php new file mode 100644 index 0000000..da4a530 --- /dev/null +++ b/tests/Feature/Api/Service/VmUpgradeAddonsTest.php @@ -0,0 +1,160 @@ +customer = Customer::factory()->create(['total' => 10_000_000]); + + $this->product = Product::factory()->create([ + 'product_type' => 'vps', + 'is_active' => true, + 'cpu' => 2, + 'ram' => 2048, + 'disk' => 40, + ]); + + $this->cpuAddon = Addon::create([ + 'type' => 'vps', + 'code' => 'cpu', + 'name' => 'CPU', + 'min' => 1, + 'max' => 8, + 'step' => 1, + 'price' => 50_000, + 'status' => true, + ]); + + $order = Order::create([ + 'order_num' => 'ORD-UPG-TEST', + 'customer_id' => $this->customer->id, + 'total' => 100_000, + 'status' => 1, + ]); + + $this->orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'quantity' => 1, + 'price' => 100_000, + 'addons_meta' => [], + ]); + + $this->vm = ModuleVmware::create([ + 'name' => 'upgrade-test-vm', + 'customer_id' => $this->customer->id, + 'order_id' => $order->id, + 'vmware_id' => 'vm-upgrade-1', + 'status' => 1, + ]); + + $this->service = Service::create([ + 'customer_id' => $this->customer->id, + 'product_id' => $this->product->id, + 'order_item_id' => $this->orderItem->id, + 'vm_id' => $this->vm->id, + 'status' => 'active', + 'started_at' => now(), + 'expires_at' => now()->addDays(15), + ]); + } + + private function actingAsCustomer(): static + { + return $this->actingAs($this->customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + public function test_upgrade_request_creates_unpaid_invoice_without_touching_vcenter(): void + { + Bus::fake(); + + $response = $this->actingAsCustomer()->postJson('/v1/system/vm-upgrade-addons', [ + 'product' => $this->vm->id, + 'cpu' => 2, + ]); + + $response->assertStatus(200)->assertJsonPath('error', false); + + $this->assertDatabaseHas('invoices', [ + 'customer_id' => $this->customer->id, + 'service_id' => $this->service->id, + 'invoice_type' => 3, + 'status' => InvoiceStatus::UNPAID->value, + ]); + + $this->assertDatabaseHas('invoice_items', [ + 'addon_id' => $this->cpuAddon->id, + 'type' => 'upgrade', + 'quantity' => 2, + ]); + + Bus::assertNotDispatched(UpdateVmConfigJob::class); + } + + public function test_qty_over_max_is_rejected(): void + { + $response = $this->actingAsCustomer()->postJson('/v1/system/vm-upgrade-addons', [ + 'product' => $this->vm->id, + 'cpu' => 999, + ]); + + $response->assertStatus(422)->assertJsonPath('error', true); + $this->assertDatabaseCount('invoices', 0); + } + + public function test_paying_the_invoice_dispatches_upgrade_job_and_updates_addons_meta(): void + { + $this->actingAsCustomer()->postJson('/v1/system/vm-upgrade-addons', [ + 'product' => $this->vm->id, + 'cpu' => 2, + ])->assertStatus(200); + + $invoice = Invoice::where('service_id', $this->service->id)->firstOrFail(); + + Bus::fake(); + + app(InvoicePaymentService::class)->payWithBalance($invoice, $this->customer->fresh(), 'balance'); + + Bus::assertDispatched(UpdateVmConfigJob::class, function (UpdateVmConfigJob $job) { + return $job->moduleVmwareId === $this->vm->id + && $job->cpuOverride === 4; // base 2 + purchased 2 + }); + + $meta = $this->orderItem->fresh()->addons_meta; + $this->assertNotEmpty($meta); + $this->assertSame(2, (int) collect($meta)->firstWhere('addon_id', $this->cpuAddon->id)['qty']); + } +} diff --git a/tests/Feature/Api/Transaction/ManualDepositProofTest.php b/tests/Feature/Api/Transaction/ManualDepositProofTest.php new file mode 100644 index 0000000..facd54b --- /dev/null +++ b/tests/Feature/Api/Transaction/ManualDepositProofTest.php @@ -0,0 +1,142 @@ +actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + public function test_confirm_rejects_proof_path_belonging_to_another_customer(): void + { + Storage::fake('local'); + + $owner = Customer::factory()->create(); + $attacker = Customer::factory()->create(); + + $ownerPath = UploadedFile::fake()->image('proof.jpg')->store('deposit-proofs/' . $owner->id, 'local'); + + $transaction = Transaction::create([ + 'uuid' => Str::uuid()->toString(), + 'public_code' => 'MAN-TEST-' . Str::random(6), + 'trans' => 'MANTEST' . time(), + 'amount' => 100000, + 'customer_id' => $attacker->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + ]); + + $response = $this->actingAsCustomer($attacker)->postJson('/v1/transaction/manual-deposit/confirm', [ + 'uuid' => $transaction->uuid, + 'proof_image' => $ownerPath, + ]); + + $response->assertStatus(422)->assertJsonPath('error', true); + $this->assertNull($transaction->fresh()->proof_image); + } + + public function test_confirm_rejects_traversal_and_reused_proof_paths(): void + { + Storage::fake('local'); + + $customer = Customer::factory()->create(); + $other = Customer::factory()->create(); + $otherPath = UploadedFile::fake()->image('proof.jpg')->store('deposit-proofs/' . $other->id, 'local'); + $usedPath = UploadedFile::fake()->image('old.jpg')->store('deposit-proofs/' . $customer->id, 'local'); + + $newTransaction = fn () => Transaction::create([ + 'uuid' => Str::uuid()->toString(), + 'public_code' => 'MAN-TEST-' . Str::random(6), + 'trans' => 'MANTEST' . Str::random(8), + 'amount' => 100000, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + ]); + + $newTransaction()->update(['proof_image' => $usedPath, 'status' => TransactionStatus::COMPLETED->value]); + + foreach (["deposit-proofs/{$customer->id}/../{$otherPath}", $usedPath] as $path) { + $transaction = $newTransaction(); + + $this->actingAsCustomer($customer)->postJson('/v1/transaction/manual-deposit/confirm', [ + 'uuid' => $transaction->uuid, + 'proof_image' => $path, + ])->assertStatus(422); + + $this->assertNull($transaction->fresh()->proof_image); + } + } + + public function test_confirm_without_proof_is_rejected(): void + { + $customer = Customer::factory()->create(); + + $transaction = Transaction::create([ + 'uuid' => Str::uuid()->toString(), + 'public_code' => 'MAN-TEST-' . Str::random(6), + 'trans' => 'MANTEST' . time(), + 'amount' => 100000, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + ]); + + $response = $this->actingAsCustomer($customer)->postJson('/v1/transaction/manual-deposit/confirm', [ + 'uuid' => $transaction->uuid, + ]); + + $response->assertStatus(422)->assertJsonPath('error', true); + } + + public function test_upload_then_confirm_binds_proof_to_owner(): void + { + Storage::fake('local'); + + $customer = Customer::factory()->create(); + + $transaction = Transaction::create([ + 'uuid' => Str::uuid()->toString(), + 'public_code' => 'MAN-TEST-' . Str::random(6), + 'trans' => 'MANTEST' . time(), + 'amount' => 100000, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + ]); + + $upload = $this->actingAsCustomer($customer)->post('/v1/transaction/manual-deposit/upload-proof', [ + 'file' => UploadedFile::fake()->image('proof.jpg'), + ]); + + $upload->assertStatus(200); + $path = $upload->json('path'); + + $this->assertStringStartsWith('deposit-proofs/' . $customer->id . '/', $path); + Storage::disk('local')->assertExists($path); + + $confirm = $this->actingAsCustomer($customer)->postJson('/v1/transaction/manual-deposit/confirm', [ + 'uuid' => $transaction->uuid, + 'proof_image' => $path, + ]); + + $confirm->assertStatus(200)->assertJsonPath('error', false); + $this->assertSame($path, $transaction->fresh()->proof_image); + } +} diff --git a/tests/Feature/Console/ReencryptSecretsTest.php b/tests/Feature/Console/ReencryptSecretsTest.php new file mode 100644 index 0000000..3c0f139 --- /dev/null +++ b/tests/Feature/Console/ReencryptSecretsTest.php @@ -0,0 +1,94 @@ +delete(); + } + + public function test_rotating_keys_re_encrypts_rsa_and_app_key_columns(): void + { + [$oldRsa, $newRsa] = [$this->rsaConfig(), $this->rsaConfig()]; + [$oldAppKey, $newAppKey] = [$this->appKey(), $this->appKey()]; + + $this->useKeys($oldRsa, $oldAppKey); + $vcenterId = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]); + $dnsServerId = DB::table('dns_manager_servers')->insertGetId([ + 'name' => 'ns1', 'ip_address' => '10.0.0.1', 'username' => 'admin', 'password' => Crypt::encryptString('plesk-secret'), + ]); + DB::table('setting')->insert(['key' => 'smtp', 'value' => json_encode(['host' => 'smtp', 'password' => SignRSA::EncryptFunc('smtp-secret')])]); + + $this->useKeys($newRsa, $newAppKey, previousRsa: $oldRsa, previousAppKey: $oldAppKey); + $this->artisan('secrets:reencrypt')->assertSuccessful(); + + $this->useKeys($newRsa, $newAppKey); + $this->assertSame('vc-secret', SignRSA::DecryptFunc(DB::table('module_vcenter')->where('id', $vcenterId)->value('password'))); + $this->assertSame('plesk-secret', Crypt::decryptString(DB::table('dns_manager_servers')->where('id', $dnsServerId)->value('password'))); + $smtp = json_decode(DB::table('setting')->where('key', 'smtp')->value('value'), true); + $this->assertSame('smtp-secret', SignRSA::DecryptFunc($smtp['password'])); + $this->assertSame('smtp', $smtp['host']); + } + + public function test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command(): void + { + $this->useKeys($this->rsaConfig(), $this->appKey()); + $id = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]); + $original = DB::table('module_vcenter')->where('id', $id)->value('password'); + + $this->artisan('secrets:reencrypt --dry-run')->assertSuccessful(); + $this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password')); + + $this->useKeys($this->rsaConfig(), $this->appKey()); + $this->artisan('secrets:reencrypt')->assertFailed(); + $this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password')); + } + + private function useKeys(array $rsa, string $appKey, ?array $previousRsa = null, ?string $previousAppKey = null): void + { + config([ + 'rsa.key' => $rsa['key'], + 'rsa.public' => $rsa['public'], + 'rsa.private' => $rsa['private'], + 'rsa.previous_key' => $previousRsa['key'] ?? null, + 'rsa.previous_public' => $previousRsa['public'] ?? null, + 'app.key' => $appKey, + 'app.previous_keys' => $previousAppKey ? [$previousAppKey] : [], + ]); + + $this->app->forgetInstance('encrypter'); + $this->app->forgetInstance(\App\Http\Services\SignRSA::class); + Crypt::clearResolvedInstance('encrypter'); + SignRSA::clearResolvedInstance(\App\Http\Services\SignRSA::class); + } + + private function rsaConfig(): array + { + $pair = openssl_pkey_new(['private_key_bits' => 2048]); + openssl_pkey_export($pair, $private); + + return [ + 'key' => base64_encode(random_bytes(24)), + 'public' => openssl_pkey_get_details($pair)['key'], + 'private' => $private, + ]; + } + + private function appKey(): string + { + return 'base64:' . base64_encode(Encrypter::generateKey('AES-256-CBC')); + } +} diff --git a/tests/Unit/BillingPricingTest.php b/tests/Unit/BillingPricingTest.php new file mode 100644 index 0000000..fa171b9 --- /dev/null +++ b/tests/Unit/BillingPricingTest.php @@ -0,0 +1,62 @@ + 1, + 'monthly' => 1, + 'quarterly' => 3, + 'semi_annually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + ]; + + foreach (ProductBillingCycle::cases() as $case) { + $this->assertSame($expected[$case->value], OrderHelper::cycleToMonths($case->value)); + } + } + + public function test_cycle_to_months_throws_on_unknown_value(): void + { + $this->expectException(\InvalidArgumentException::class); + OrderHelper::cycleToMonths('semiannually'); + } + + public function test_cycle_to_months_throws_on_null(): void + { + $this->expectException(\InvalidArgumentException::class); + OrderHelper::cycleToMonths(null); + } + + /** @dataProvider domainPriceProvider */ + public function test_domain_price_formula(int $setupFee, int $firstYear, int $renewal, int $years, int $expected): void + { + $pricing = new DomainPricing([ + 'setup_fee' => (string) $setupFee, + 'first_year' => $firstYear, + 'recurring_fee' => (string) $renewal, + ]); + + $this->assertSame($expected, $pricing->orderPrice($years)); + } + + public static function domainPriceProvider(): array + { + return [ + 'years=1, no setup fee' => [0, 200000, 350000, 1, 200000], + 'years=1, with setup fee' => [50000, 200000, 350000, 1, 250000], + 'years=3, no setup fee' => [0, 200000, 350000, 3, 900000], + 'years=3, with setup fee' => [50000, 200000, 350000, 3, 950000], + ]; + } +} diff --git a/tests/Unit/Models/ReinstallLogPlainPasswordTest.php b/tests/Unit/Models/ReinstallLogPlainPasswordTest.php new file mode 100644 index 0000000..752eaf7 --- /dev/null +++ b/tests/Unit/Models/ReinstallLogPlainPasswordTest.php @@ -0,0 +1,45 @@ + 1, + 'product_type' => ReinstallLog::PRODUCT_TYPE_VPS, + 'status' => ReinstallLog::STATUS_PENDING, + 'plain_password' => 'S3cr3t!pass', + ]); + + $raw = DB::table('reinstall_logs')->where('id', $log->id)->value('plain_password'); + + $this->assertNotSame('S3cr3t!pass', $raw); + $this->assertSame('S3cr3t!pass', $log->fresh()->plain_password); + } + + public function test_backfill_migration_is_idempotent(): void + { + DB::table('reinstall_logs')->insert([ + 'product_id' => 1, + 'product_type' => ReinstallLog::PRODUCT_TYPE_VPS, + 'status' => ReinstallLog::STATUS_PENDING, + 'plain_password' => 'plaintext-legacy-row', + ]); + + $migration = require database_path('migrations/2026_09_26_100000_encrypt_reinstall_logs_plain_password.php'); + $migration->up(); + $migration->up(); + + $row = DB::table('reinstall_logs')->where('plain_password', '!=', '')->whereNotNull('plain_password')->first(); + $this->assertSame('plaintext-legacy-row', \Illuminate\Support\Facades\Crypt::decryptString($row->plain_password)); + } +} diff --git a/tests/Unit/Services/VCenter/PowerShellServiceSecretLoggingTest.php b/tests/Unit/Services/VCenter/PowerShellServiceSecretLoggingTest.php new file mode 100644 index 0000000..de45712 --- /dev/null +++ b/tests/Unit/Services/VCenter/PowerShellServiceSecretLoggingTest.php @@ -0,0 +1,74 @@ +> */ + private array $capturedLogs = []; + + public function test_change_ip_vm_linux_never_logs_the_guest_password(): void + { + $this->interceptLogs(); + + (new PowerShellService(new Client()))->changeIpVM( + vmName: 'vm-test', + vmwareId: 'vm-3340', + ipAddress: '10.0.0.5', + gateway: '10.0.0.1', + osType: 'linux', + guestUser: 'root', + guestPassword: self::SECRET, + ); + + $this->assertLogsNeverContainSecret(); + } + + public function test_change_ip_vm_windows_never_logs_the_guest_password(): void + { + $this->interceptLogs(); + + (new PowerShellService(new Client()))->changeIpVM( + vmName: 'vm-test', + vmwareId: 'vm-3340', + ipAddress: '10.0.0.5', + gateway: '10.0.0.1', + osType: 'windows', + guestUser: 'administrator', + guestPassword: self::SECRET, + ); + + $this->assertLogsNeverContainSecret(); + } + + private function interceptLogs(): void + { + $collect = function (...$args): void { + $this->capturedLogs[] = $args; + }; + + Log::shouldReceive('channel')->andReturnSelf(); + Log::shouldReceive('info')->andReturnUsing($collect); + Log::shouldReceive('warning')->andReturnUsing($collect); + Log::shouldReceive('error')->andReturnUsing($collect); + } + + private function assertLogsNeverContainSecret(): void + { + $this->assertNotEmpty($this->capturedLogs, 'Expected at least one log call to have been made.'); + + foreach ($this->capturedLogs as $call) { + $this->assertStringNotContainsString(self::SECRET, (string) json_encode($call)); + } + } +} diff --git a/tests/Unit/Support/SshHostKeyVerifierTest.php b/tests/Unit/Support/SshHostKeyVerifierTest.php new file mode 100644 index 0000000..2369802 --- /dev/null +++ b/tests/Unit/Support/SshHostKeyVerifierTest.php @@ -0,0 +1,49 @@ + true, 'vmware.ssh_known_hosts' => null]); + + $this->expectException(RuntimeException::class); + + SshHostKeyVerifier::verify($this->createMock(SSH2::class), '10.0.0.1'); + } + + public function test_missing_known_hosts_is_allowed_when_tls_verification_is_off(): void + { + config(['vmware.tls_verify' => false, 'vmware.ssh_known_hosts' => null]); + + SshHostKeyVerifier::verify($this->createMock(SSH2::class), '10.0.0.1'); + + $this->addToAssertionCount(1); + } + + public function test_host_key_must_match_known_hosts_entry(): void + { + $knownHosts = tempnam(sys_get_temp_dir(), 'kh'); + file_put_contents($knownHosts, "10.0.0.1 ssh-ed25519 AAAAexpected\n"); + config(['vmware.tls_verify' => true, 'vmware.ssh_known_hosts' => $knownHosts]); + + $matching = $this->createMock(SSH2::class); + $matching->method('getServerPublicHostKey')->willReturn('ssh-ed25519 AAAAexpected'); + SshHostKeyVerifier::verify($matching, '10.0.0.1'); + + $spoofed = $this->createMock(SSH2::class); + $spoofed->method('getServerPublicHostKey')->willReturn('ssh-ed25519 AAAAattacker'); + $this->expectException(RuntimeException::class); + try { + SshHostKeyVerifier::verify($spoofed, '10.0.0.1'); + } finally { + unlink($knownHosts); + } + } +} diff --git a/tests/Unit/Support/TlsHttpClientFactoryTest.php b/tests/Unit/Support/TlsHttpClientFactoryTest.php new file mode 100644 index 0000000..421995b --- /dev/null +++ b/tests/Unit/Support/TlsHttpClientFactoryTest.php @@ -0,0 +1,53 @@ + true, 'vmware.ca_bundle' => null]); + + $this->assertTrue(TlsHttpClientFactory::vCenter()->getOptions()['verify']); + } + + public function test_verify_true_with_ca_bundle_pins_the_bundle_path(): void + { + config(['vmware.tls_verify' => true, 'vmware.ca_bundle' => '/etc/ssl/vcenter-ca.pem']); + + $this->assertSame('/etc/ssl/vcenter-ca.pem', TlsHttpClientFactory::vCenter()->getOptions()['verify']); + } + + public function test_verify_false_disables_verification(): void + { + config(['vmware.tls_verify' => false, 'vmware.ca_bundle' => '/etc/ssl/vcenter-ca.pem']); + + $this->assertFalse(TlsHttpClientFactory::vCenter()->getOptions()['verify']); + } + + public function test_plesk_reads_its_own_config(): void + { + config(['vmware.tls_verify' => false, 'services.plesk.tls_verify' => true, 'services.plesk.ca_bundle' => '/etc/ssl/plesk-ca.pem']); + + $this->assertSame('/etc/ssl/plesk-ca.pem', TlsHttpClientFactory::plesk()->getOptions()['verify']); + } + + public function test_curl_options_follow_vcenter_config(): void + { + config(['vmware.tls_verify' => true, 'vmware.ca_bundle' => '/etc/ssl/vcenter-ca.pem']); + $this->assertSame([ + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_CAINFO => '/etc/ssl/vcenter-ca.pem', + ], TlsHttpClientFactory::vCenterCurlOptions()); + + config(['vmware.tls_verify' => false]); + $this->assertSame([ + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + ], TlsHttpClientFactory::vCenterCurlOptions()); + } +}