From b6c8ac343f26a200ca5556cb1caef3e65a259a26 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 6 Jun 2026 19:18:30 +0000 Subject: [PATCH] Extract Ladill Servers as standalone app at servers.ladill.com. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor --- .editorconfig | 18 + .env.example | 76 + .gitattributes | 11 + .gitea/workflows/deploy.yml | 92 + .gitignore | 24 + DEPLOY.md | 76 + README.md | 59 + REVISION | 1 + .../AuditHostingPhpFpmPoolsCommand.php | 107 + .../Commands/CheckNodeHealthCommand.php | 162 + .../HardenHostingAccountIsolationCommand.php | 52 + app/Console/Commands/ImportHostingCommand.php | 331 + .../Commands/NotifyExpiringHostingCommand.php | 24 + .../ProcessExpiredHostingAccountsCommand.php | 124 + ...ProvisionPendingHostingAccountsCommand.php | 66 + .../RecalculateHostingNodeCapacityCommand.php | 41 + .../Commands/RenewSslCertificatesCommand.php | 48 + app/Console/Commands/RepairSslCommand.php | 59 + .../Commands/ReprovisionHostingAccount.php | 62 + .../RetryPendingHostingFulfillmentCommand.php | 43 + .../RunHostingTerminalWorkerCommand.php | 217 + app/Console/Commands/SetupNodeSshKey.php | 227 + .../SyncHostingAccountUsageCommand.php | 91 + app/Exceptions/ContaboApiException.php | 58 + .../Controllers/Auth/SsoLoginController.php | 140 + app/Http/Controllers/Controller.php | 10 + .../Controllers/Email/AccountController.php | 276 + app/Http/Controllers/Email/AfiaController.php | 76 + .../Controllers/Email/DeveloperController.php | 45 + .../Controllers/Email/DomainsController.php | 85 + .../Email/MailboxLinkBannerController.php | 17 + .../Controllers/Email/MailboxesController.php | 254 + .../Controllers/Email/OverviewController.php | 41 + app/Http/Controllers/Email/TeamController.php | 114 + .../Controllers/Hosting/AccountController.php | 106 + .../Controllers/Hosting/AfiaController.php | 101 + .../Hosting/DeveloperController.php | 45 + .../Controllers/Hosting/HostingController.php | 278 + .../Hosting/HostingPanelController.php | 2914 ++++++ .../Hosting/HostingProductController.php | 648 ++ .../HostingTerminalSessionController.php | 159 + .../Hosting/MailboxLinkBannerController.php | 17 + .../Hosting/OverviewController.php | 53 + .../Controllers/Hosting/SearchController.php | 56 + .../Hosting/ServerPanelController.php | 299 + .../Controllers/Hosting/TeamController.php | 114 + .../Controllers/NotificationController.php | 64 + app/Http/Middleware/SetActingAccount.php | 38 + app/Jobs/DeactivateMailboxJob.php | 61 + app/Jobs/MailProvisioningJob.php | 120 + app/Jobs/MigrateHostingAccountJob.php | 109 + .../ProvisionContaboInfrastructureJob.php | 58 + app/Jobs/ProvisionDomainSlaveZoneJob.php | 38 + app/Jobs/ProvisionDomainZoneJob.php | 115 + app/Jobs/ProvisionHostingAccountJob.php | 154 + app/Jobs/ProvisionHostingOrderJob.php | 403 + app/Jobs/ProvisionHostingSslJob.php | 114 + app/Jobs/ProvisionMailboxJob.php | 70 + app/Jobs/SslProvisioningJob.php | 106 + app/Models/AppInstallation.php | 116 + app/Models/ContaboInfrastructureProvision.php | 113 + app/Models/CustomerHostingOrder.php | 317 + app/Models/Domain.php | 124 + app/Models/DomainDkimKey.php | 30 + app/Models/DomainDnsRecord.php | 36 + app/Models/DomainNsCheck.php | 43 + app/Models/DomainOrder.php | 166 + app/Models/DomainPricing.php | 230 + app/Models/EmailDomain.php | 260 + app/Models/EmailServer.php | 148 + app/Models/EmailSetting.php | 13 + app/Models/EmailTeamMember.php | 48 + app/Models/HostedDatabase.php | 35 + app/Models/HostedSite.php | 65 + app/Models/HostingAccount.php | 377 + app/Models/HostingAccountAlert.php | 43 + app/Models/HostingAccountMember.php | 72 + app/Models/HostingBillingInvoice.php | 62 + app/Models/HostingNode.php | 250 + app/Models/HostingOrder.php | 436 + app/Models/HostingPlan.php | 123 + app/Models/HostingPlanChange.php | 75 + app/Models/HostingProduct.php | 308 + app/Models/HostingSetting.php | 13 + app/Models/HostingTeamMember.php | 48 + app/Models/Mailbox.php | 151 + app/Models/NodeCapacityAlert.php | 75 + app/Models/PlatformSetting.php | 28 + app/Models/PromoBanner.php | 113 + app/Models/ProvisioningJob.php | 94 + app/Models/ProvisioningQueueItem.php | 114 + app/Models/RcServiceOrder.php | 93 + app/Models/ServerAgent.php | 72 + app/Models/ServerDatabase.php | 53 + app/Models/ServerFile.php | 48 + app/Models/ServerSite.php | 60 + app/Models/ServerTask.php | 98 + app/Models/User.php | 52 + app/Models/VpsInstance.php | 72 + app/Models/Website.php | 126 + app/Models/WebsiteMember.php | 28 + .../DomainVerifiedNotification.php | 45 + .../HostingActivatedNotification.php | 53 + .../HostingDeveloperAddedNotification.php | 54 + .../HostingExpiringNotification.php | 68 + .../HostingResourceWarningNotification.php | 47 + .../HostingSuspendedNotification.php | 48 + app/Notifications/SslExpiringNotification.php | 47 + .../SslProvisionedNotification.php | 45 + app/Policies/HostingAccountPolicy.php | 114 + app/Providers/AppServiceProvider.php | 21 + app/Services/Afia/AfiaService.php | 156 + app/Services/Billing/BillingClient.php | 87 + app/Services/Billing/PaystackService.php | 164 + .../Currency/CurrencyConverterService.php | 166 + app/Services/Dns/PowerDnsClient.php | 304 + app/Services/Domain/DomainClient.php | 36 + app/Services/Domain/DomainDkimService.php | 82 + .../Domain/DomainDnsBlueprintService.php | 176 + app/Services/Domain/DomainPricingService.php | 285 + .../Domain/DomainRegistrarService.php | 2020 ++++ app/Services/Domain/DomainRegistryService.php | 137 + .../Domain/DomainVerificationService.php | 519 + .../EmailDomain/EmailDomainClient.php | 77 + app/Services/ExchangeRateService.php | 52 + app/Services/Hosting/AppInstaller.php | 464 + .../Hosting/BrowserTerminalSessionManager.php | 373 + .../Contracts/AppInstallerInterface.php | 25 + .../Contracts/ComputeProviderInterface.php | 36 + .../Contracts/PanelRuntimeInterface.php | 79 + .../SharedHostingProviderInterface.php | 49 + .../Hosting/HostingExpiryAlertService.php | 77 + .../Hosting/HostingMailboxService.php | 190 + .../Hosting/HostingNodePoolService.php | 86 + .../HostingOrderFulfillmentService.php | 68 + .../Hosting/HostingPlanChangeService.php | 322 + .../Hosting/HostingPricingService.php | 242 + .../Hosting/HostingProvisioningService.php | 390 + .../Hosting/HostingRenewalCheckoutService.php | 242 + .../Hosting/HostingResourcePolicyService.php | 642 ++ app/Services/Hosting/NodeCapacityService.php | 376 + .../Hosting/NodeMonitoringService.php | 497 + app/Services/Hosting/PanelRuntimeResolver.php | 30 + .../PanelRuntimes/ServerAgentPanelRuntime.php | 140 + .../SharedHostingPanelRuntime.php | 149 + .../Hosting/Providers/ContaboProvider.php | 553 ++ .../Hosting/Providers/SharedNodeProvider.php | 2814 ++++++ .../Hosting/ResellerClubHostingService.php | 662 ++ .../Hosting/ServerAgentBootstrapService.php | 202 + .../Hosting/ServerAgentInstallerService.php | 229 + .../Hosting/ServerAgentReleaseService.php | 117 + app/Services/Hosting/ServerOrderService.php | 942 ++ .../Hosting/ServerPanelCapabilityService.php | 181 + .../Hosting/ServerTaskDispatchService.php | 224 + .../Hosting/ServerTaskResultService.php | 557 ++ .../Hosting/Terminal/LocalPtyShellSession.php | 137 + .../Terminal/RemoteSshShellSession.php | 97 + .../Hosting/Terminal/ShellSession.php | 18 + .../Hosting/UpsellRecommendationService.php | 201 + app/Services/Identity/IdentityClient.php | 57 + .../ContaboInfrastructureCloudInitBuilder.php | 176 + .../ContaboInfrastructureProvisioner.php | 350 + .../ContaboInfrastructureSshKeyService.php | 34 + .../InfrastructureUsageReportService.php | 74 + app/Services/Mail/EmailServerPoolService.php | 154 + app/Services/Mail/MailServerNfsService.php | 58 + app/Services/Mail/MailServerProvisioner.php | 369 + app/Services/Mail/MailboxUserProvisioner.php | 113 + app/Services/Mailbox/MailboxClient.php | 95 + app/Services/Payment/WalletPaymentService.php | 99 + .../ResellerClub/RcOrderCartService.php | 587 ++ .../RcOrderFulfillmentService.php | 275 + ...llerClubConnectivityDiagnosticsService.php | 328 + .../ResellerClubConsoleService.php | 50 + .../ResellerClubCustomerService.php | 655 ++ .../ResellerClubProductService.php | 1249 +++ .../Ssl/SslCertificateExpiryParser.php | 29 + app/Services/Ssl/SslProvisioner.php | 331 + app/Services/Ssl/SslRenewalService.php | 329 + app/Support/DomainConfig.php | 369 + app/Support/DomainGlobeIcon.php | 28 + app/Support/MailboxPricing.php | 69 + app/Support/ResellerClubLegacy.php | 122 + app/Support/helpers.php | 38 + app/View/Composers/MailboxLinkComposer.php | 86 + artisan | 18 + bootstrap/app.php | 26 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 94 + composer.lock | 8696 +++++++++++++++++ config/afia.php | 10 + config/app.php | 135 + config/auth.php | 117 + config/billing.php | 14 + config/cache.php | 117 + config/database.php | 184 + config/domain.php | 6 + config/email.php | 21 + config/emaildomain.php | 7 + config/filesystems.php | 80 + config/hosting.php | 703 ++ config/identity.php | 6 + config/ladill_launcher.php | 34 + config/logging.php | 132 + config/mail.php | 118 + config/mailbox.php | 9 + config/queue.php | 129 + config/services.php | 62 + config/session.php | 217 + database/.gitignore | 1 + database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 52 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 57 + ...2026_03_17_120000_create_domains_table.php | 55 + ..._18_140000_create_hosting_orders_table.php | 37 + ..._220000_create_rc_service_orders_table.php | 36 + ...kout_fields_to_rc_service_orders_table.php | 74 + ...3_24_081519_create_hosting_nodes_table.php | 245 + ..._121800_create_hosting_platform_tables.php | 174 + ...g_product_id_to_hosting_accounts_table.php | 29 + ..._id_nullable_in_hosting_accounts_table.php | 29 + ...credentials_to_app_installations_table.php | 35 + ...progress_tracking_to_app_installations.php | 26 + ..._hosting_order_billing_cycle_to_string.php | 27 + ...4_04_100000_create_server_agents_table.php | 38 + ...04_04_100100_create_server_tasks_table.php | 39 + ...email_to_customer_hosting_orders_table.php | 30 + ...lable_in_customer_hosting_orders_table.php | 25 + ...1447_sync_hosting_catalog_plan_updates.php | 151 + ...ds_to_hosting_nodes_and_accounts_table.php | 239 + ...sting_accounts_and_create_alerts_table.php | 244 + ...lan_changes_and_billing_invoices_table.php | 85 + ...0_create_hosting_account_members_table.php | 29 + ...lumns_to_hosting_account_members_table.php | 23 + ...4_add_ssl_status_to_hosted_sites_table.php | 30 + ..._shared_hosting_inode_limits_by_200000.php | 82 + ...esources_and_relax_resource_suspension.php | 169 + ...00_sync_contabo_server_product_pricing.php | 110 + ...00002_add_pool_fields_to_hosting_nodes.php | 38 + ...ontabo_infrastructure_provisions_table.php | 49 + ...0004_update_hosting_node_disk_to_150gb.php | 23 + ...000001_create_email_team_members_table.php | 32 + ..._05_000002_create_email_settings_table.php | 26 + ...48_create_personal_access_tokens_table.php | 33 + ...6_000001_create_hosting_settings_table.php | 24 + ...0001_create_hosting_team_members_table.php | 32 + ...d_platform_id_to_hosting_import_tables.php | 42 + ...6_06_200000_create_notifications_table.php | 25 + database/seeders/DatabaseSeeder.php | 29 + database/seeders/HostingNodeSeeder.php | 40 + database/seeders/HostingProductSeeder.php | 280 + deploy/bin/ladill-hosting-admin | 126 + deploy/bin/ladill-hosting-user | 21 + deploy/deploy.sh | 213 + deploy/sudoers.ladill-hosting.example | 7 + package-lock.json | 2532 +++++ package.json | 22 + phpunit.xml | 36 + public/.htaccess | 25 + public/favicon.ico | Bin 0 -> 8786 bytes public/images/ladill-icons/bird.svg | 20 + public/images/ladill-icons/domains.svg | 20 + public/images/ladill-icons/pro.svg | 3 + public/images/ladill-icons/qrplus.svg | 15 + public/images/ladill-icons/server.svg | 1 + public/images/ladill-icons/wordpress.svg | 23 + public/images/launcher-icons/bird.svg | 20 + public/images/launcher-icons/domains.svg | 20 + public/images/launcher-icons/email.svg | 25 + public/images/launcher-icons/hosting.svg | 40 + public/images/launcher-icons/mail.svg | 25 + public/images/launcher-icons/qrplus.svg | 25 + public/images/launcher-icons/servers.svg | 35 + .../images/logo/ladilldomains-logo-white.svg | 34 + public/images/logo/ladilldomains-logo.svg | 45 + public/images/logo/ladillemail-logo.svg | 48 + public/images/logo/ladillhosting-logo.svg | 61 + public/images/logo/ladillservers-logo.svg | 56 + public/index.php | 20 + public/robots.txt | 2 + resources/css/app.css | 16 + resources/js/app.js | 184 + resources/js/bootstrap.js | 4 + .../views/components/app-layout.blade.php | 33 + .../components/hosting-panel-layout.blade.php | 73 + .../components/icons/domain-globe.blade.php | 4 + .../icons/multi-domain-hosting.blade.php | 6 + .../views/components/icons/qr-code.blade.php | 5 + .../icons/single-domain-hosting.blade.php | 7 + .../components/icons/unlimited.blade.php | 6 + .../views/components/user-layout.blade.php | 33 + .../views/email/account/billing.blade.php | 52 + .../views/email/account/developers.blade.php | 70 + .../views/email/account/settings.blade.php | 101 + resources/views/email/account/team.blade.php | 82 + .../views/email/account/wallet.blade.php | 27 + resources/views/email/dashboard.blade.php | 47 + resources/views/email/domains/index.blade.php | 30 + resources/views/email/domains/show.blade.php | 62 + .../views/email/mailboxes/create.blade.php | 95 + .../views/email/mailboxes/index.blade.php | 32 + .../views/email/mailboxes/show.blade.php | 83 + .../views/email/mailboxes/upgrade.blade.php | 52 + resources/views/email/signed-out.blade.php | 17 + resources/views/hosting/account.blade.php | 694 ++ .../views/hosting/account/billing.blade.php | 28 + .../hosting/account/developers.blade.php | 65 + .../views/hosting/account/settings.blade.php | 34 + .../views/hosting/account/team.blade.php | 80 + .../views/hosting/account/wallet.blade.php | 27 + .../views/hosting/accounts-list.blade.php | 82 + resources/views/hosting/dashboard.blade.php | 103 + resources/views/hosting/order.blade.php | 255 + resources/views/hosting/panel/apps.blade.php | 551 ++ resources/views/hosting/panel/cron.blade.php | 136 + .../views/hosting/panel/databases.blade.php | 148 + .../views/hosting/panel/domains.blade.php | 256 + resources/views/hosting/panel/files.blade.php | 782 ++ resources/views/hosting/panel/index.blade.php | 156 + resources/views/hosting/panel/logs.blade.php | 80 + .../hosting/panel/partials/sidebar.blade.php | 220 + resources/views/hosting/panel/php.blade.php | 91 + .../views/hosting/panel/settings.blade.php | 160 + resources/views/hosting/panel/ssl.blade.php | 100 + .../views/hosting/panel/terminal.blade.php | 107 + .../partials/order-form-fields.blade.php | 183 + .../partials/server-order-modal.blade.php | 448 + resources/views/hosting/select-type.blade.php | 64 + .../views/hosting/server-order.blade.php | 304 + .../views/hosting/server-panel/show.blade.php | 498 + resources/views/hosting/server-type.blade.php | 225 + resources/views/hosting/show.blade.php | 424 + resources/views/hosting/signed-out.blade.php | 17 + resources/views/hosting/type.blade.php | 381 + resources/views/layouts/email.blade.php | 60 + resources/views/layouts/hosting.blade.php | 59 + resources/views/notifications/_list.blade.php | 93 + resources/views/notifications/index.blade.php | 10 + resources/views/partials/afia.blade.php | 102 + resources/views/partials/favicon.blade.php | 3 + resources/views/partials/flash.blade.php | 22 + .../views/partials/ladill-pro-icon.blade.php | 2 + resources/views/partials/launcher.blade.php | 53 + .../partials/mailbox-link-banner.blade.php | 46 + .../partials/mobile-bottom-nav.blade.php | 137 + .../partials/mobile-header-cart.blade.php | 9 + .../partials/notification-dropdown.blade.php | 106 + .../views/partials/paystack-sheet.blade.php | 54 + resources/views/partials/sidebar.blade.php | 44 + resources/views/partials/topbar.blade.php | 99 + .../views/partials/wordpress-icon.blade.php | 2 + resources/views/servers/dashboard.blade.php | 96 + resources/views/servers/signed-out.blade.php | 17 + resources/views/welcome.blade.php | 278 + routes/api.php | 19 + routes/console.php | 17 + routes/web.php | 83 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + tests/Feature/AccountPagesTest.php | 76 + .../AdminHostingAccountDurationTest.php | 273 + .../AdminHostingAccountUnsuspendTest.php | 142 + ...rdenHostingAccountIsolationCommandTest.php | 85 + .../HostingOrderFulfillmentServiceTest.php | 107 + tests/Feature/HostingPanelAppInstallTest.php | 306 + .../Feature/HostingPanelDomainLinkingTest.php | 263 + tests/Feature/HostingPanelSslTest.php | 150 + tests/Feature/HostingPanelTerminalTest.php | 158 + tests/Feature/HostingPhaseThreeApiTest.php | 374 + tests/Feature/HostingPricingDisplayTest.php | 779 ++ .../HostingResourcePolicyServiceTest.php | 343 + tests/Feature/HostingTeamAccessTest.php | 438 + .../HostingUpsellRecommendationTest.php | 283 + tests/Feature/MailboxesTest.php | 172 + tests/Feature/MarketingHostingCatalogTest.php | 30 + .../ProvisionHostingOrderJobSharedTest.php | 132 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + vite.config.js | 18 + 382 files changed, 67315 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100644 REVISION create mode 100644 app/Console/Commands/AuditHostingPhpFpmPoolsCommand.php create mode 100644 app/Console/Commands/CheckNodeHealthCommand.php create mode 100644 app/Console/Commands/HardenHostingAccountIsolationCommand.php create mode 100644 app/Console/Commands/ImportHostingCommand.php create mode 100644 app/Console/Commands/NotifyExpiringHostingCommand.php create mode 100644 app/Console/Commands/ProcessExpiredHostingAccountsCommand.php create mode 100644 app/Console/Commands/ProvisionPendingHostingAccountsCommand.php create mode 100644 app/Console/Commands/RecalculateHostingNodeCapacityCommand.php create mode 100644 app/Console/Commands/RenewSslCertificatesCommand.php create mode 100644 app/Console/Commands/RepairSslCommand.php create mode 100644 app/Console/Commands/ReprovisionHostingAccount.php create mode 100644 app/Console/Commands/RetryPendingHostingFulfillmentCommand.php create mode 100644 app/Console/Commands/RunHostingTerminalWorkerCommand.php create mode 100644 app/Console/Commands/SetupNodeSshKey.php create mode 100644 app/Console/Commands/SyncHostingAccountUsageCommand.php create mode 100644 app/Exceptions/ContaboApiException.php create mode 100644 app/Http/Controllers/Auth/SsoLoginController.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/Email/AccountController.php create mode 100644 app/Http/Controllers/Email/AfiaController.php create mode 100644 app/Http/Controllers/Email/DeveloperController.php create mode 100644 app/Http/Controllers/Email/DomainsController.php create mode 100644 app/Http/Controllers/Email/MailboxLinkBannerController.php create mode 100644 app/Http/Controllers/Email/MailboxesController.php create mode 100644 app/Http/Controllers/Email/OverviewController.php create mode 100644 app/Http/Controllers/Email/TeamController.php create mode 100644 app/Http/Controllers/Hosting/AccountController.php create mode 100644 app/Http/Controllers/Hosting/AfiaController.php create mode 100644 app/Http/Controllers/Hosting/DeveloperController.php create mode 100644 app/Http/Controllers/Hosting/HostingController.php create mode 100644 app/Http/Controllers/Hosting/HostingPanelController.php create mode 100644 app/Http/Controllers/Hosting/HostingProductController.php create mode 100644 app/Http/Controllers/Hosting/HostingTerminalSessionController.php create mode 100644 app/Http/Controllers/Hosting/MailboxLinkBannerController.php create mode 100644 app/Http/Controllers/Hosting/OverviewController.php create mode 100644 app/Http/Controllers/Hosting/SearchController.php create mode 100644 app/Http/Controllers/Hosting/ServerPanelController.php create mode 100644 app/Http/Controllers/Hosting/TeamController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Http/Middleware/SetActingAccount.php create mode 100644 app/Jobs/DeactivateMailboxJob.php create mode 100644 app/Jobs/MailProvisioningJob.php create mode 100644 app/Jobs/MigrateHostingAccountJob.php create mode 100644 app/Jobs/ProvisionContaboInfrastructureJob.php create mode 100644 app/Jobs/ProvisionDomainSlaveZoneJob.php create mode 100644 app/Jobs/ProvisionDomainZoneJob.php create mode 100644 app/Jobs/ProvisionHostingAccountJob.php create mode 100644 app/Jobs/ProvisionHostingOrderJob.php create mode 100644 app/Jobs/ProvisionHostingSslJob.php create mode 100644 app/Jobs/ProvisionMailboxJob.php create mode 100644 app/Jobs/SslProvisioningJob.php create mode 100644 app/Models/AppInstallation.php create mode 100644 app/Models/ContaboInfrastructureProvision.php create mode 100644 app/Models/CustomerHostingOrder.php create mode 100644 app/Models/Domain.php create mode 100644 app/Models/DomainDkimKey.php create mode 100644 app/Models/DomainDnsRecord.php create mode 100644 app/Models/DomainNsCheck.php create mode 100644 app/Models/DomainOrder.php create mode 100644 app/Models/DomainPricing.php create mode 100644 app/Models/EmailDomain.php create mode 100644 app/Models/EmailServer.php create mode 100644 app/Models/EmailSetting.php create mode 100644 app/Models/EmailTeamMember.php create mode 100644 app/Models/HostedDatabase.php create mode 100644 app/Models/HostedSite.php create mode 100644 app/Models/HostingAccount.php create mode 100644 app/Models/HostingAccountAlert.php create mode 100644 app/Models/HostingAccountMember.php create mode 100644 app/Models/HostingBillingInvoice.php create mode 100644 app/Models/HostingNode.php create mode 100644 app/Models/HostingOrder.php create mode 100644 app/Models/HostingPlan.php create mode 100644 app/Models/HostingPlanChange.php create mode 100644 app/Models/HostingProduct.php create mode 100644 app/Models/HostingSetting.php create mode 100644 app/Models/HostingTeamMember.php create mode 100644 app/Models/Mailbox.php create mode 100644 app/Models/NodeCapacityAlert.php create mode 100644 app/Models/PlatformSetting.php create mode 100644 app/Models/PromoBanner.php create mode 100644 app/Models/ProvisioningJob.php create mode 100644 app/Models/ProvisioningQueueItem.php create mode 100644 app/Models/RcServiceOrder.php create mode 100644 app/Models/ServerAgent.php create mode 100644 app/Models/ServerDatabase.php create mode 100644 app/Models/ServerFile.php create mode 100644 app/Models/ServerSite.php create mode 100644 app/Models/ServerTask.php create mode 100644 app/Models/User.php create mode 100644 app/Models/VpsInstance.php create mode 100644 app/Models/Website.php create mode 100644 app/Models/WebsiteMember.php create mode 100644 app/Notifications/DomainVerifiedNotification.php create mode 100644 app/Notifications/HostingActivatedNotification.php create mode 100644 app/Notifications/HostingDeveloperAddedNotification.php create mode 100644 app/Notifications/HostingExpiringNotification.php create mode 100644 app/Notifications/HostingResourceWarningNotification.php create mode 100644 app/Notifications/HostingSuspendedNotification.php create mode 100644 app/Notifications/SslExpiringNotification.php create mode 100644 app/Notifications/SslProvisionedNotification.php create mode 100644 app/Policies/HostingAccountPolicy.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100644 app/Services/Afia/AfiaService.php create mode 100644 app/Services/Billing/BillingClient.php create mode 100644 app/Services/Billing/PaystackService.php create mode 100644 app/Services/Currency/CurrencyConverterService.php create mode 100644 app/Services/Dns/PowerDnsClient.php create mode 100644 app/Services/Domain/DomainClient.php create mode 100644 app/Services/Domain/DomainDkimService.php create mode 100644 app/Services/Domain/DomainDnsBlueprintService.php create mode 100644 app/Services/Domain/DomainPricingService.php create mode 100644 app/Services/Domain/DomainRegistrarService.php create mode 100644 app/Services/Domain/DomainRegistryService.php create mode 100644 app/Services/Domain/DomainVerificationService.php create mode 100644 app/Services/EmailDomain/EmailDomainClient.php create mode 100644 app/Services/ExchangeRateService.php create mode 100644 app/Services/Hosting/AppInstaller.php create mode 100644 app/Services/Hosting/BrowserTerminalSessionManager.php create mode 100644 app/Services/Hosting/Contracts/AppInstallerInterface.php create mode 100644 app/Services/Hosting/Contracts/ComputeProviderInterface.php create mode 100644 app/Services/Hosting/Contracts/PanelRuntimeInterface.php create mode 100644 app/Services/Hosting/Contracts/SharedHostingProviderInterface.php create mode 100644 app/Services/Hosting/HostingExpiryAlertService.php create mode 100644 app/Services/Hosting/HostingMailboxService.php create mode 100644 app/Services/Hosting/HostingNodePoolService.php create mode 100644 app/Services/Hosting/HostingOrderFulfillmentService.php create mode 100644 app/Services/Hosting/HostingPlanChangeService.php create mode 100644 app/Services/Hosting/HostingPricingService.php create mode 100644 app/Services/Hosting/HostingProvisioningService.php create mode 100644 app/Services/Hosting/HostingRenewalCheckoutService.php create mode 100644 app/Services/Hosting/HostingResourcePolicyService.php create mode 100644 app/Services/Hosting/NodeCapacityService.php create mode 100644 app/Services/Hosting/NodeMonitoringService.php create mode 100644 app/Services/Hosting/PanelRuntimeResolver.php create mode 100644 app/Services/Hosting/PanelRuntimes/ServerAgentPanelRuntime.php create mode 100644 app/Services/Hosting/PanelRuntimes/SharedHostingPanelRuntime.php create mode 100644 app/Services/Hosting/Providers/ContaboProvider.php create mode 100644 app/Services/Hosting/Providers/SharedNodeProvider.php create mode 100644 app/Services/Hosting/ResellerClubHostingService.php create mode 100644 app/Services/Hosting/ServerAgentBootstrapService.php create mode 100644 app/Services/Hosting/ServerAgentInstallerService.php create mode 100644 app/Services/Hosting/ServerAgentReleaseService.php create mode 100644 app/Services/Hosting/ServerOrderService.php create mode 100644 app/Services/Hosting/ServerPanelCapabilityService.php create mode 100644 app/Services/Hosting/ServerTaskDispatchService.php create mode 100644 app/Services/Hosting/ServerTaskResultService.php create mode 100644 app/Services/Hosting/Terminal/LocalPtyShellSession.php create mode 100644 app/Services/Hosting/Terminal/RemoteSshShellSession.php create mode 100644 app/Services/Hosting/Terminal/ShellSession.php create mode 100644 app/Services/Hosting/UpsellRecommendationService.php create mode 100644 app/Services/Identity/IdentityClient.php create mode 100644 app/Services/Infrastructure/ContaboInfrastructureCloudInitBuilder.php create mode 100644 app/Services/Infrastructure/ContaboInfrastructureProvisioner.php create mode 100644 app/Services/Infrastructure/ContaboInfrastructureSshKeyService.php create mode 100644 app/Services/Infrastructure/InfrastructureUsageReportService.php create mode 100644 app/Services/Mail/EmailServerPoolService.php create mode 100644 app/Services/Mail/MailServerNfsService.php create mode 100644 app/Services/Mail/MailServerProvisioner.php create mode 100644 app/Services/Mail/MailboxUserProvisioner.php create mode 100644 app/Services/Mailbox/MailboxClient.php create mode 100644 app/Services/Payment/WalletPaymentService.php create mode 100644 app/Services/ResellerClub/RcOrderCartService.php create mode 100644 app/Services/ResellerClub/RcOrderFulfillmentService.php create mode 100644 app/Services/ResellerClub/ResellerClubConnectivityDiagnosticsService.php create mode 100644 app/Services/ResellerClub/ResellerClubConsoleService.php create mode 100644 app/Services/ResellerClub/ResellerClubCustomerService.php create mode 100644 app/Services/ResellerClub/ResellerClubProductService.php create mode 100644 app/Services/Ssl/SslCertificateExpiryParser.php create mode 100644 app/Services/Ssl/SslProvisioner.php create mode 100644 app/Services/Ssl/SslRenewalService.php create mode 100644 app/Support/DomainConfig.php create mode 100644 app/Support/DomainGlobeIcon.php create mode 100644 app/Support/MailboxPricing.php create mode 100644 app/Support/ResellerClubLegacy.php create mode 100644 app/Support/helpers.php create mode 100644 app/View/Composers/MailboxLinkComposer.php create mode 100755 artisan create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/afia.php create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/billing.php create mode 100644 config/cache.php create mode 100644 config/database.php create mode 100644 config/domain.php create mode 100644 config/email.php create mode 100644 config/emaildomain.php create mode 100644 config/filesystems.php create mode 100644 config/hosting.php create mode 100644 config/identity.php create mode 100644 config/ladill_launcher.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/mailbox.php create mode 100644 config/queue.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/migrations/2026_03_17_120000_create_domains_table.php create mode 100644 database/migrations/2026_03_18_140000_create_hosting_orders_table.php create mode 100644 database/migrations/2026_03_18_220000_create_rc_service_orders_table.php create mode 100644 database/migrations/2026_03_19_120000_add_checkout_fields_to_rc_service_orders_table.php create mode 100644 database/migrations/2026_03_24_081519_create_hosting_nodes_table.php create mode 100644 database/migrations/2026_03_24_121800_create_hosting_platform_tables.php create mode 100644 database/migrations/2026_03_26_201008_add_hosting_product_id_to_hosting_accounts_table.php create mode 100644 database/migrations/2026_03_26_201149_make_hosting_plan_id_nullable_in_hosting_accounts_table.php create mode 100644 database/migrations/2026_03_29_111141_add_encrypted_credentials_to_app_installations_table.php create mode 100644 database/migrations/2026_03_29_154900_add_progress_tracking_to_app_installations.php create mode 100644 database/migrations/2026_04_02_121500_change_customer_hosting_order_billing_cycle_to_string.php create mode 100644 database/migrations/2026_04_04_100000_create_server_agents_table.php create mode 100644 database/migrations/2026_04_04_100100_create_server_tasks_table.php create mode 100644 database/migrations/2026_04_05_175627_add_guest_email_to_customer_hosting_orders_table.php create mode 100644 database/migrations/2026_04_05_180932_make_user_id_nullable_in_customer_hosting_orders_table.php create mode 100644 database/migrations/2026_04_05_231447_sync_hosting_catalog_plan_updates.php create mode 100644 database/migrations/2026_04_05_235500_add_capacity_and_segment_fields_to_hosting_nodes_and_accounts_table.php create mode 100644 database/migrations/2026_04_06_001500_add_resource_policy_fields_to_hosting_accounts_and_create_alerts_table.php create mode 100644 database/migrations/2026_04_06_120000_create_hosting_plan_changes_and_billing_invoices_table.php create mode 100644 database/migrations/2026_04_06_150000_create_hosting_account_members_table.php create mode 100644 database/migrations/2026_04_06_160000_add_ssh_key_columns_to_hosting_account_members_table.php create mode 100644 database/migrations/2026_04_09_111734_add_ssl_status_to_hosted_sites_table.php create mode 100644 database/migrations/2026_04_10_120000_increase_shared_hosting_inode_limits_by_200000.php create mode 100644 database/migrations/2026_04_24_220000_increase_shared_hosting_resources_and_relax_resource_suspension.php create mode 100644 database/migrations/2026_05_17_120000_sync_contabo_server_product_pricing.php create mode 100644 database/migrations/2026_05_30_100002_add_pool_fields_to_hosting_nodes.php create mode 100644 database/migrations/2026_05_30_120000_create_contabo_infrastructure_provisions_table.php create mode 100644 database/migrations/2026_05_30_200004_update_hosting_node_disk_to_150gb.php create mode 100644 database/migrations/2026_06_05_000001_create_email_team_members_table.php create mode 100644 database/migrations/2026_06_05_000002_create_email_settings_table.php create mode 100644 database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_06_06_000001_create_hosting_settings_table.php create mode 100644 database/migrations/2026_06_06_000001_create_hosting_team_members_table.php create mode 100644 database/migrations/2026_06_06_120000_add_platform_id_to_hosting_import_tables.php create mode 100644 database/migrations/2026_06_06_200000_create_notifications_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 database/seeders/HostingNodeSeeder.php create mode 100644 database/seeders/HostingProductSeeder.php create mode 100755 deploy/bin/ladill-hosting-admin create mode 100755 deploy/bin/ladill-hosting-user create mode 100755 deploy/deploy.sh create mode 100644 deploy/sudoers.ladill-hosting.example create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/images/ladill-icons/bird.svg create mode 100644 public/images/ladill-icons/domains.svg create mode 100644 public/images/ladill-icons/pro.svg create mode 100644 public/images/ladill-icons/qrplus.svg create mode 100644 public/images/ladill-icons/server.svg create mode 100644 public/images/ladill-icons/wordpress.svg create mode 100644 public/images/launcher-icons/bird.svg create mode 100644 public/images/launcher-icons/domains.svg create mode 100644 public/images/launcher-icons/email.svg create mode 100644 public/images/launcher-icons/hosting.svg create mode 100644 public/images/launcher-icons/mail.svg create mode 100644 public/images/launcher-icons/qrplus.svg create mode 100644 public/images/launcher-icons/servers.svg create mode 100644 public/images/logo/ladilldomains-logo-white.svg create mode 100644 public/images/logo/ladilldomains-logo.svg create mode 100644 public/images/logo/ladillemail-logo.svg create mode 100644 public/images/logo/ladillhosting-logo.svg create mode 100644 public/images/logo/ladillservers-logo.svg create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/app.css create mode 100644 resources/js/app.js create mode 100644 resources/js/bootstrap.js create mode 100644 resources/views/components/app-layout.blade.php create mode 100644 resources/views/components/hosting-panel-layout.blade.php create mode 100644 resources/views/components/icons/domain-globe.blade.php create mode 100644 resources/views/components/icons/multi-domain-hosting.blade.php create mode 100644 resources/views/components/icons/qr-code.blade.php create mode 100644 resources/views/components/icons/single-domain-hosting.blade.php create mode 100644 resources/views/components/icons/unlimited.blade.php create mode 100644 resources/views/components/user-layout.blade.php create mode 100644 resources/views/email/account/billing.blade.php create mode 100644 resources/views/email/account/developers.blade.php create mode 100644 resources/views/email/account/settings.blade.php create mode 100644 resources/views/email/account/team.blade.php create mode 100644 resources/views/email/account/wallet.blade.php create mode 100644 resources/views/email/dashboard.blade.php create mode 100644 resources/views/email/domains/index.blade.php create mode 100644 resources/views/email/domains/show.blade.php create mode 100644 resources/views/email/mailboxes/create.blade.php create mode 100644 resources/views/email/mailboxes/index.blade.php create mode 100644 resources/views/email/mailboxes/show.blade.php create mode 100644 resources/views/email/mailboxes/upgrade.blade.php create mode 100644 resources/views/email/signed-out.blade.php create mode 100644 resources/views/hosting/account.blade.php create mode 100644 resources/views/hosting/account/billing.blade.php create mode 100644 resources/views/hosting/account/developers.blade.php create mode 100644 resources/views/hosting/account/settings.blade.php create mode 100644 resources/views/hosting/account/team.blade.php create mode 100644 resources/views/hosting/account/wallet.blade.php create mode 100644 resources/views/hosting/accounts-list.blade.php create mode 100644 resources/views/hosting/dashboard.blade.php create mode 100644 resources/views/hosting/order.blade.php create mode 100644 resources/views/hosting/panel/apps.blade.php create mode 100644 resources/views/hosting/panel/cron.blade.php create mode 100644 resources/views/hosting/panel/databases.blade.php create mode 100644 resources/views/hosting/panel/domains.blade.php create mode 100644 resources/views/hosting/panel/files.blade.php create mode 100644 resources/views/hosting/panel/index.blade.php create mode 100644 resources/views/hosting/panel/logs.blade.php create mode 100644 resources/views/hosting/panel/partials/sidebar.blade.php create mode 100644 resources/views/hosting/panel/php.blade.php create mode 100644 resources/views/hosting/panel/settings.blade.php create mode 100644 resources/views/hosting/panel/ssl.blade.php create mode 100644 resources/views/hosting/panel/terminal.blade.php create mode 100644 resources/views/hosting/partials/order-form-fields.blade.php create mode 100644 resources/views/hosting/partials/server-order-modal.blade.php create mode 100644 resources/views/hosting/select-type.blade.php create mode 100644 resources/views/hosting/server-order.blade.php create mode 100644 resources/views/hosting/server-panel/show.blade.php create mode 100644 resources/views/hosting/server-type.blade.php create mode 100644 resources/views/hosting/show.blade.php create mode 100644 resources/views/hosting/signed-out.blade.php create mode 100644 resources/views/hosting/type.blade.php create mode 100644 resources/views/layouts/email.blade.php create mode 100644 resources/views/layouts/hosting.blade.php create mode 100644 resources/views/notifications/_list.blade.php create mode 100644 resources/views/notifications/index.blade.php create mode 100644 resources/views/partials/afia.blade.php create mode 100644 resources/views/partials/favicon.blade.php create mode 100644 resources/views/partials/flash.blade.php create mode 100644 resources/views/partials/ladill-pro-icon.blade.php create mode 100644 resources/views/partials/launcher.blade.php create mode 100644 resources/views/partials/mailbox-link-banner.blade.php create mode 100644 resources/views/partials/mobile-bottom-nav.blade.php create mode 100644 resources/views/partials/mobile-header-cart.blade.php create mode 100644 resources/views/partials/notification-dropdown.blade.php create mode 100644 resources/views/partials/paystack-sheet.blade.php create mode 100644 resources/views/partials/sidebar.blade.php create mode 100644 resources/views/partials/topbar.blade.php create mode 100644 resources/views/partials/wordpress-icon.blade.php create mode 100644 resources/views/servers/dashboard.blade.php create mode 100644 resources/views/servers/signed-out.blade.php create mode 100644 resources/views/welcome.blade.php create mode 100644 routes/api.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 tests/Feature/AccountPagesTest.php create mode 100644 tests/Feature/AdminHostingAccountDurationTest.php create mode 100644 tests/Feature/AdminHostingAccountUnsuspendTest.php create mode 100644 tests/Feature/HardenHostingAccountIsolationCommandTest.php create mode 100644 tests/Feature/HostingOrderFulfillmentServiceTest.php create mode 100644 tests/Feature/HostingPanelAppInstallTest.php create mode 100644 tests/Feature/HostingPanelDomainLinkingTest.php create mode 100644 tests/Feature/HostingPanelSslTest.php create mode 100644 tests/Feature/HostingPanelTerminalTest.php create mode 100644 tests/Feature/HostingPhaseThreeApiTest.php create mode 100644 tests/Feature/HostingPricingDisplayTest.php create mode 100644 tests/Feature/HostingResourcePolicyServiceTest.php create mode 100644 tests/Feature/HostingTeamAccessTest.php create mode 100644 tests/Feature/HostingUpsellRecommendationTest.php create mode 100644 tests/Feature/MailboxesTest.php create mode 100644 tests/Feature/MarketingHostingCatalogTest.php create mode 100644 tests/Feature/ProvisionHostingOrderJobSharedTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 vite.config.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9972c98 --- /dev/null +++ b/.env.example @@ -0,0 +1,76 @@ +APP_NAME="Ladill Servers" +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://servers.ladill.com + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US +APP_MAINTENANCE_DRIVER=file + +PLATFORM_URL=https://ladill.com +PLATFORM_DOMAIN=ladill.com +AUTH_DOMAIN=auth.ladill.com +ACCOUNT_DOMAIN=account.ladill.com +HOSTING_DOMAIN=hosting.ladill.com +SERVERS_DOMAIN=servers.ladill.com + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=warning + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=ladill_servers +DB_USERNAME=ladill_servers +DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=.ladill.com + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database +CACHE_STORE=database + +LADILL_SSO_CLIENT_ID= +LADILL_SSO_CLIENT_SECRET= + +BILLING_API_URL=https://ladill.com/api/billing +BILLING_API_KEY_SERVERS= + +IDENTITY_API_URL=https://ladill.com/api +IDENTITY_API_KEY_SERVERS= + +DOMAIN_API_URL=https://ladill.com/api/domains +DOMAIN_API_KEY_SERVERS= + +LADILL_HOSTING_API_URL=https://httpapi.com/api +LADILL_HOSTING_DEFAULT_PLAN_ID= +HOSTING_PHPMYADMIN_URL= +HOSTING_PHPMYADMIN_SSO_SECRET= + +CONTABO_CLIENT_ID= +CONTABO_CLIENT_SECRET= +CONTABO_API_USER= +CONTABO_API_PASSWORD= +CONTABO_PRODUCT_CATALOG_ENDPOINT= +CONTABO_PRICE_CACHE_TTL=3600 + +PDNS_API_URL= +PDNS_API_KEY= + +LADILL_RESELLERCLUB_TLD_PRODUCT_KEYS=com=domcno,net=domnet,org=domorg +LADILL_RESELLERCLUB_CONTROL_PANEL_URL=https://manage.resellerclub.com/customer + +AFIA_API_KEY= +AFIA_PROVIDER=openai +AFIA_MODEL=gpt-4o-mini + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..73bf01e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,92 @@ +name: Deploy Ladill Servers + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-servers + cancel-in-progress: true + +jobs: + deploy: + runs-on: deploy + env: + NODE_ROOT: /tmp/ladill-node-22-r1 + NODE_VERSION: "22.14.0" + RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + WORKSPACE: /tmp/${{ gitea.repository_owner }}-servers-${{ gitea.run_id }}-${{ gitea.run_attempt }} + LADILL_APP_ROOT: /var/www/ladill-servers + steps: + - name: Checkout + shell: bash {0} + run: | + set -Eeuo pipefail + DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)" + REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git" + rm -rf "$WORKSPACE" + mkdir -p "$WORKSPACE" + export GIT_TERMINAL_PROMPT=0 + git \ + -c credential.helper= \ + -c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \ + clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \ + "$REPO_URL" "$WORKSPACE" + + - name: Setup Node.js + shell: bash {0} + run: | + set -Eeuo pipefail + if [ ! -x "$NODE_ROOT/bin/node" ]; then + rm -rf "$NODE_ROOT" + mkdir -p "$NODE_ROOT" + case "$(uname -m)" in + x86_64|amd64) NODE_ARCH="x64" ;; + aarch64|arm64) NODE_ARCH="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + | tar -xJ --strip-components=1 -C "$NODE_ROOT" + fi + "$NODE_ROOT/bin/node" -v + + - name: Build frontend assets + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + export PATH="$NODE_ROOT/bin:$PATH" + npm ci --no-audit --no-fund + npm run build + + - name: Build release archive + shell: bash {0} + run: | + set -Eeuo pipefail + cd "$WORKSPACE" + printf '%s\n' "${{ gitea.sha }}" > REVISION + rm -f "$RELEASE_ARCHIVE" + tar -czf "$RELEASE_ARCHIVE" \ + --exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \ + --exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests . + + - name: Deploy release + shell: bash {0} + env: + LADILL_RELEASE_ARCHIVE: /tmp/ladill-servers-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + run: | + set -Eeuo pipefail + : "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}" + bash "$WORKSPACE/deploy/deploy.sh" + + - name: Cleanup + if: always() + shell: bash {0} + run: | + rm -rf "$WORKSPACE" + rm -f "$RELEASE_ARCHIVE" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71b1ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..1e2c695 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,76 @@ +# Ladill Servers — deploy & cutover runbook + +Standalone app for **VPS and dedicated servers** at `servers.ladill.com`. +Shared web hosting lives on `hosting.ladill.com`. + +Mirrors the Ladill Hosting / Domains deployment model (app-slot + own DB + Gitea CI). + +--- + +## 1. Gitea repo + CI + +1. Repo: **ladill-servers** on Gitea +2. Push to `main` triggers `.gitea/workflows/deploy.yml` +3. Deploy path: `/var/www/ladill-servers/current` + +## 2. Server app-slot + database + +```bash +sudo install -d -o deploy -g www-data /var/www/ladill-servers + +sudo mysql -e "CREATE DATABASE ladill_servers CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +sudo mysql -e "CREATE USER 'ladill_servers'@'127.0.0.1' IDENTIFIED BY '';" +sudo mysql -e "GRANT ALL ON ladill_servers.* TO 'ladill_servers'@'127.0.0.1'; FLUSH PRIVILEGES;" +``` + +Create `/var/www/ladill-servers/.env` from `.env.example`, fill secrets, +then `php artisan key:generate`. + +## 3. Register the OIDC client (platform / auth.ladill.com) + +```bash +php artisan passport:client \ + --name="Ladill Servers" \ + --redirect_uri="https://servers.ladill.com/sso/callback" +``` + +Set `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` in this app's `.env`. + +## 4. Platform integration + +On the **platform** `.env`: + +```env +BILLING_API_KEY_SERVERS= +IDENTITY_API_KEY_SERVERS= +RP_SERVERS_FRONTCHANNEL_LOGOUT=https://servers.ladill.com/sso/logout-frontchannel +LADILL_SERVERS_APP_URL=https://servers.ladill.com +``` + +Then `php artisan config:cache` on the platform. + +## 5. nginx + TLS + +```bash +sudo deployment/setup-service-subdomain-nginx.sh servers --app ladill-servers +``` + +## 6. First deploy + +Push to `main`, then: + +```bash +cd /var/www/ladill-servers/current +php artisan migrate --force +php artisan db:seed --class=HostingProductSeeder --force +php artisan config:cache route:cache view:cache +``` + +Ensure a queue worker runs via supervisor (`ladill-servers-worker:*`). + +## 7. Smoke test + +- `https://servers.ladill.com/up` → 200 +- Logged-out → SSO redirect to auth.ladill.com +- Logged-in → dashboard, VPS and dedicated pages load +- Launcher shows Servers icon; home hub no longer "Soon" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0165a77 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +

Laravel Logo

+ +

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

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com)** +- **[Tighten Co.](https://tighten.co)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Redberry](https://redberry.international/laravel-development)** +- **[Active Logic](https://activelogic.com)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/REVISION b/REVISION new file mode 100644 index 0000000..df4b9b8 --- /dev/null +++ b/REVISION @@ -0,0 +1 @@ +manual-deploy diff --git a/app/Console/Commands/AuditHostingPhpFpmPoolsCommand.php b/app/Console/Commands/AuditHostingPhpFpmPoolsCommand.php new file mode 100644 index 0000000..53bf8d6 --- /dev/null +++ b/app/Console/Commands/AuditHostingPhpFpmPoolsCommand.php @@ -0,0 +1,107 @@ +with('node') + ->where('type', HostingAccount::TYPE_SHARED) + ->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId)) + ->orderBy('id') + ->get(); + + if ($accounts->isEmpty()) { + $this->info('No hosting accounts matched.'); + + return self::SUCCESS; + } + + $issues = 0; + $repaired = 0; + $failures = 0; + + foreach ($accounts as $account) { + if (! $account->node) { + $this->warn("[{$account->id}] {$account->username}: no node assigned"); + + continue; + } + + try { + $versions = $provider->findPhpFpmPoolVersions($account); + } catch (\Throwable $e) { + $failures++; + $this->error("[{$account->id}] {$account->username}: {$e->getMessage()}"); + + continue; + } + + if ($versions === []) { + $this->line("[{$account->id}] {$account->username}: no pool configs found"); + + continue; + } + + $targetVersion = $account->php_version ?? '8.4'; + $status = count($versions) === 1 && $versions[0] === $targetVersion + ? 'ok' + : 'duplicate'; + + $this->line(sprintf( + '[%d] %s: pools=%s target=%s (%s)', + $account->id, + $account->username, + implode(', ', $versions), + $targetVersion, + $status, + )); + + if ($status === 'ok') { + continue; + } + + $issues++; + + if (! $this->option('repair')) { + continue; + } + + try { + if ($provider->changeAccountPhpVersion($account, $targetVersion)) { + $repaired++; + $this->info(" repaired {$account->username}"); + } else { + $failures++; + $this->error(" failed to repair {$account->username}"); + } + } catch (\Throwable $e) { + $failures++; + $this->error(" failed to repair {$account->username}: {$e->getMessage()}"); + } + } + + $this->newLine(); + $this->info("Accounts scanned: {$accounts->count()}"); + $this->info("Issues found: {$issues}"); + + if ($this->option('repair')) { + $this->info("Repaired: {$repaired}"); + $this->info("Failures: {$failures}"); + } + + return $failures > 0 ? self::FAILURE : ($issues > 0 && ! $this->option('repair') ? self::FAILURE : self::SUCCESS); + } +} diff --git a/app/Console/Commands/CheckNodeHealthCommand.php b/app/Console/Commands/CheckNodeHealthCommand.php new file mode 100644 index 0000000..7202bb5 --- /dev/null +++ b/app/Console/Commands/CheckNodeHealthCommand.php @@ -0,0 +1,162 @@ +option('node'); + + if ($nodeId) { + return $this->checkSingleNode($monitoringService, (int) $nodeId); + } + + return $this->checkAllNodes($monitoringService); + } + + private function checkSingleNode(NodeMonitoringService $monitoringService, int $nodeId): int + { + $node = HostingNode::find($nodeId); + + if (!$node) { + $this->error("Node with ID {$nodeId} not found."); + return self::FAILURE; + } + + $this->info("Checking node: {$node->name} ({$node->ip_address})..."); + + $result = $monitoringService->checkNode($node); + + $this->displayNodeResult($node, $result); + + return self::SUCCESS; + } + + private function checkAllNodes(NodeMonitoringService $monitoringService): int + { + $nodes = HostingNode::whereIn('status', [ + HostingNode::STATUS_ACTIVE, + HostingNode::STATUS_FULL, + HostingNode::STATUS_MAINTENANCE, + ])->get(); + + if ($nodes->isEmpty()) { + $this->warn('No active nodes found to check.'); + return self::SUCCESS; + } + + $this->info("Checking {$nodes->count()} node(s)..."); + $this->newLine(); + + $results = []; + $bar = $this->output->createProgressBar($nodes->count()); + $bar->start(); + + foreach ($nodes as $node) { + $results[$node->id] = $monitoringService->checkNode($node); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $this->displaySummaryTable($nodes, $results); + + $unhealthy = collect($results)->filter(fn($r) => ($r['health'] ?? '') !== NodeMonitoringService::HEALTH_OK); + if ($unhealthy->isNotEmpty()) { + $this->newLine(); + $this->warn("⚠️ {$unhealthy->count()} node(s) require attention."); + } + + return self::SUCCESS; + } + + private function displayNodeResult(HostingNode $node, array $result): void + { + $health = $result['health'] ?? 'unknown'; + $healthIcon = match ($health) { + NodeMonitoringService::HEALTH_OK => '✅', + NodeMonitoringService::HEALTH_WARNING => '⚠️', + NodeMonitoringService::HEALTH_CRITICAL => '🔴', + NodeMonitoringService::HEALTH_UNREACHABLE => '❌', + default => '❓', + }; + + $this->newLine(); + $this->info("{$healthIcon} Health: " . strtoupper($health)); + $this->newLine(); + + if (isset($result['error'])) { + $this->error("Error: {$result['error']}"); + return; + } + + $this->table( + ['Metric', 'Value'], + [ + ['CPU Usage', ($result['cpu']['usage_percent'] ?? 'N/A') . '%'], + ['RAM Usage', ($result['memory']['usage_percent'] ?? 'N/A') . '%'], + ['Disk Usage (root)', ($result['disk']['root']['usage_percent'] ?? 'N/A') . '%'], + ['Load (1m)', $result['load']['load_1'] ?? 'N/A'], + ['Load per Core', $result['load']['load_per_core'] ?? 'N/A'], + ['Uptime', $result['uptime']['formatted'] ?? 'N/A'], + ['Processes', $result['processes']['total'] ?? 'N/A'], + ['Response Time', ($result['response_time_ms'] ?? 'N/A') . 'ms'], + ] + ); + + if (!empty($result['services'])) { + $this->newLine(); + $this->info('Services:'); + foreach ($result['services'] as $service => $running) { + $icon = $running ? '✅' : '❌'; + $this->line(" {$icon} {$service}"); + } + } + } + + private function displaySummaryTable($nodes, array $results): void + { + $rows = []; + + foreach ($nodes as $node) { + $result = $results[$node->id] ?? []; + $health = $result['health'] ?? 'unknown'; + + $healthIcon = match ($health) { + NodeMonitoringService::HEALTH_OK => '✅', + NodeMonitoringService::HEALTH_WARNING => '⚠️', + NodeMonitoringService::HEALTH_CRITICAL => '🔴', + NodeMonitoringService::HEALTH_UNREACHABLE => '❌', + default => '❓', + }; + + $rows[] = [ + $node->name, + $node->ip_address, + "{$healthIcon} " . strtoupper($health), + isset($result['cpu']['usage_percent']) ? $result['cpu']['usage_percent'] . '%' : 'N/A', + isset($result['memory']['usage_percent']) ? $result['memory']['usage_percent'] . '%' : 'N/A', + isset($result['disk']['root']['usage_percent']) ? $result['disk']['root']['usage_percent'] . '%' : 'N/A', + $result['load']['load_1'] ?? 'N/A', + "{$node->current_accounts}/{$node->max_accounts}", + ]; + } + + $this->table( + ['Node', 'IP', 'Health', 'CPU', 'RAM', 'Disk', 'Load', 'Accounts'], + $rows + ); + } +} diff --git a/app/Console/Commands/HardenHostingAccountIsolationCommand.php b/app/Console/Commands/HardenHostingAccountIsolationCommand.php new file mode 100644 index 0000000..f8f8518 --- /dev/null +++ b/app/Console/Commands/HardenHostingAccountIsolationCommand.php @@ -0,0 +1,52 @@ +with(['node', 'sites']) + ->where('type', HostingAccount::TYPE_SHARED) + ->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId)) + ->orderBy('id') + ->get(); + + if ($accounts->isEmpty()) { + $this->info('No hosting accounts matched.'); + return self::SUCCESS; + } + + foreach ($accounts as $account) { + $this->line(sprintf('[%d] %s (%s)', $account->id, $account->username, $account->primary_domain ?: 'no-domain')); + } + + if ($this->option('dry-run')) { + $this->warn('Dry run mode: no changes applied.'); + return self::SUCCESS; + } + + $failures = 0; + + foreach ($accounts as $account) { + try { + $provider->hardenAccountFilesystem($account); + $this->info("Hardened {$account->username}"); + } catch (\Throwable $e) { + $failures++; + $this->error("Failed {$account->username}: {$e->getMessage()}"); + } + } + + return $failures === 0 ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Console/Commands/ImportHostingCommand.php b/app/Console/Commands/ImportHostingCommand.php new file mode 100644 index 0000000..d692622 --- /dev/null +++ b/app/Console/Commands/ImportHostingCommand.php @@ -0,0 +1,331 @@ + platform user_id → local user id */ + private array $userMap = []; + + /** @var array platform account id → local account id */ + private array $accountMap = []; + + /** @var array platform node id → local node id */ + private array $nodeMap = []; + + public function handle(): int + { + $commit = (bool) $this->option('commit'); + + if (! $commit) { + $this->warn('DRY RUN — no changes will be written. Re-run with --commit to apply.'); + } + + $path = $this->argument('file'); + if (! is_file($path)) { + $this->error("File not found: {$path}"); + + return self::FAILURE; + } + + $payload = json_decode((string) file_get_contents($path), true); + if (! is_array($payload)) { + $this->error('Invalid JSON export file.'); + + return self::FAILURE; + } + + $apply = function (callable $callback) use ($commit): void { + if ($commit) { + DB::transaction($callback); + } else { + $callback(); + } + }; + + $apply(function () use ($payload, $commit): void { + foreach ($payload['hosting_nodes'] ?? [] as $row) { + $this->importNode($row, $commit); + } + foreach ($payload['hosting_accounts'] ?? [] as $row) { + $this->importAccount($row, $commit); + } + foreach ($payload['hosted_sites'] ?? [] as $row) { + $this->importSite($row, $commit); + } + foreach ($payload['customer_hosting_orders'] ?? [] as $row) { + $this->importCustomerOrder($row, $commit); + } + foreach ($payload['hosting_orders'] ?? [] as $row) { + $this->importLegacyOrder($row, $commit); + } + foreach ($payload['hosting_account_members'] ?? [] as $row) { + $this->importMember($row, $commit); + } + foreach ($payload['hosting_billing_invoices'] ?? [] as $row) { + $this->importInvoice($row, $commit); + } + foreach ($payload['hosting_plan_changes'] ?? [] as $row) { + $this->importPlanChange($row, $commit); + } + }); + + $this->info(($commit ? 'Imported' : 'Would import').' '.count($this->accountMap).' hosting account(s).'); + $siteCount = count($payload['hosted_sites'] ?? []); + if ($siteCount > 0) { + $this->info(($commit ? 'Imported' : 'Would import')." {$siteCount} hosted site(s)."); + } + + return self::SUCCESS; + } + + /** @param array $row */ + private function importNode(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + unset($row['id'], $row['primary_hosting_node_id']); + + if ($commit) { + $node = HostingNode::updateOrCreate(['hostname' => $row['hostname']], $row); + $this->nodeMap[$platformId] = $node->id; + } else { + $this->nodeMap[$platformId] = $platformId; + $this->line(" node {$platformId} → {$row['hostname']}"); + } + } + + /** @param array $row */ + private function importAccount(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + $owner = $this->resolveUser( + (string) ($row['owner_public_id'] ?? ''), + (string) ($row['owner_email'] ?? ''), + ); + if (! $owner) { + $this->warn("Skipping account #{$platformId}: no owner public_id"); + + return; + } + + unset($row['id'], $row['owner_public_id'], $row['owner_email']); + $row['user_id'] = $owner->id; + $row['platform_id'] = $platformId; + $this->mapProductId($row); + + $platformNodeId = (int) ($row['hosting_node_id'] ?? 0); + if ($platformNodeId && isset($this->nodeMap[$platformNodeId])) { + $row['hosting_node_id'] = $this->nodeMap[$platformNodeId]; + } + + if ($commit) { + $account = HostingAccount::query()->where('platform_id', $platformId)->first(); + + if (! $account && ($row['username'] ?? '') !== '') { + $account = HostingAccount::query()->where('username', $row['username'])->first(); + } + + if ($account) { + $account->update($row); + } else { + $account = HostingAccount::create($row); + } + + $this->accountMap[$platformId] = $account->id; + } else { + $this->accountMap[$platformId] = $platformId; + $this->line(" account {$platformId} → user {$owner->email}"); + } + } + + /** @param array $row */ + private function importSite(array $row, bool $commit): void + { + $platformAccountId = (int) ($row['platform_hosting_account_id'] ?? 0); + $localAccountId = $this->accountMap[$platformAccountId] ?? null; + + if (! $localAccountId) { + $this->warn("Skipping site {$row['domain']}: unknown hosting account #{$platformAccountId}"); + + return; + } + + unset($row['platform_hosting_account_id'], $row['platform_id']); + $row['hosting_account_id'] = $localAccountId; + $row['domain_id'] = null; + + if ($commit) { + HostedSite::withTrashed()->updateOrCreate( + ['hosting_account_id' => $localAccountId, 'domain' => $row['domain']], + $row + ); + } else { + $this->line(" site {$row['domain']} → account {$localAccountId}"); + } + } + + /** @param array $row */ + private function importCustomerOrder(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + $owner = $this->resolveUser((string) ($row['owner_public_id'] ?? '')); + if (! $owner) { + return; + } + + unset($row['id'], $row['owner_public_id'], $row['owner_email']); + $row['user_id'] = $owner->id; + $row['platform_id'] = $platformId; + $this->mapProductId($row); + + if ($commit) { + CustomerHostingOrder::updateOrCreate(['platform_id' => $platformId], $row); + } + } + + /** @param array $row */ + private function importLegacyOrder(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + $owner = $this->resolveUser((string) ($row['owner_public_id'] ?? '')); + if (! $owner) { + return; + } + + unset($row['id'], $row['owner_public_id'], $row['owner_email']); + $row['user_id'] = $owner->id; + $row['platform_id'] = $platformId; + + if ($commit) { + HostingOrder::updateOrCreate(['platform_id' => $platformId], $row); + } + } + + /** @param array $row */ + private function importMember(array $row, bool $commit): void + { + $member = $this->resolveUser((string) ($row['member_public_id'] ?? '')); + $platformAccountId = (int) ($row['hosting_account_id'] ?? 0); + $localAccountId = $this->accountMap[$platformAccountId] ?? null; + + if (! $member || ! $localAccountId) { + return; + } + + unset($row['id'], $row['owner_public_id'], $row['member_public_id']); + $row['user_id'] = $member->id; + $row['hosting_account_id'] = $localAccountId; + + if (! empty($row['invited_by_user_id']) && ! User::query()->whereKey($row['invited_by_user_id'])->exists()) { + $row['invited_by_user_id'] = null; + } + + if ($commit) { + HostingAccountMember::updateOrCreate( + ['hosting_account_id' => $localAccountId, 'user_id' => $member->id], + $row + ); + } + } + + /** @param array $row */ + private function importInvoice(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + $owner = $this->resolveUser((string) ($row['owner_public_id'] ?? '')); + $platformAccountId = (int) ($row['hosting_account_id'] ?? 0); + $localAccountId = $this->accountMap[$platformAccountId] ?? null; + + if (! $owner || ! $localAccountId) { + return; + } + + unset($row['id'], $row['owner_public_id'], $row['owner_email']); + $row['user_id'] = $owner->id; + $row['hosting_account_id'] = $localAccountId; + $row['platform_id'] = $platformId; + + if ($commit) { + HostingBillingInvoice::updateOrCreate(['platform_id' => $platformId], $row); + } + } + + /** @param array $row */ + private function importPlanChange(array $row, bool $commit): void + { + $platformId = (int) ($row['id'] ?? 0); + $owner = $this->resolveUser((string) ($row['owner_public_id'] ?? '')); + $platformAccountId = (int) ($row['hosting_account_id'] ?? 0); + $localAccountId = $this->accountMap[$platformAccountId] ?? null; + + if (! $owner || ! $localAccountId) { + return; + } + + unset($row['id'], $row['owner_public_id'], $row['owner_email']); + $row['user_id'] = $owner->id; + $row['hosting_account_id'] = $localAccountId; + $row['platform_id'] = $platformId; + + if ($commit) { + HostingPlanChange::updateOrCreate(['platform_id' => $platformId], $row); + } + } + + private function resolveUser(string $publicId, string $email = '', string $name = ''): ?User + { + if ($publicId === '') { + return null; + } + + $user = User::query()->where('public_id', $publicId)->first(); + if ($user || $email === '') { + return $user; + } + + return User::query()->create([ + 'public_id' => $publicId, + 'email' => $email, + 'name' => $name !== '' ? $name : $email, + ]); + } + + /** @param array $row */ + private function mapProductId(array &$row): void + { + $slug = (string) ($row['hosting_product_slug'] ?? ''); + unset($row['hosting_product_slug']); + + if ($slug === '') { + return; + } + + $product = HostingProduct::query()->where('slug', $slug)->first(); + if ($product) { + $row['hosting_product_id'] = $product->id; + } + } +} diff --git a/app/Console/Commands/NotifyExpiringHostingCommand.php b/app/Console/Commands/NotifyExpiringHostingCommand.php new file mode 100644 index 0000000..0bce0b9 --- /dev/null +++ b/app/Console/Commands/NotifyExpiringHostingCommand.php @@ -0,0 +1,24 @@ +checkAll(); + + $this->info($sent > 0 + ? "Sent {$sent} hosting expiry reminder(s)." + : 'No hosting expiry reminders to send.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ProcessExpiredHostingAccountsCommand.php b/app/Console/Commands/ProcessExpiredHostingAccountsCommand.php new file mode 100644 index 0000000..2ed8047 --- /dev/null +++ b/app/Console/Commands/ProcessExpiredHostingAccountsCommand.php @@ -0,0 +1,124 @@ +suspendNewlyExpired($nodeProvider); + $this->terminatePastGracePeriod($provisioning); + + return self::SUCCESS; + } + + private function suspendNewlyExpired(SharedNodeProvider $nodeProvider): void + { + $accounts = HostingAccount::query() + ->where('status', HostingAccount::STATUS_ACTIVE) + ->whereNotNull('expires_at') + ->where('expires_at', '<', now()->subDays(self::EXPIRY_SUSPENSION_GRACE_DAYS)) + ->with(['node', 'sites', 'user']) + ->get(); + + if ($accounts->isEmpty()) { + $this->info('No newly expired accounts to suspend.'); + return; + } + + $this->info("Found {$accounts->count()} expired account(s) to suspend."); + + foreach ($accounts as $account) { + try { + $nodeProvider->serveExpiredPage($account); + + $reason = 'Hosting account expired on '.$account->expires_at->format('M d, Y').'. Renew to restore service.'; + + $account->update([ + 'status' => HostingAccount::STATUS_SUSPENDED, + 'suspension_reason' => $reason, + 'suspended_at' => now(), + 'metadata' => array_merge((array) ($account->metadata ?? []), [ + 'suspension_origin' => 'expiry', + 'expired_at' => $account->expires_at->toIso8601String(), + 'expiry_suspension_grace_days' => self::EXPIRY_SUSPENSION_GRACE_DAYS, + 'grace_period_ends_at' => $account->gracePeriodEndsAt()->toIso8601String(), + ]), + ]); + + if ($account->user) { + $account->user->notify(new HostingSuspendedNotification($account->fresh(), $reason)); + } + + Log::info('ProcessExpiredHostingAccounts: Suspended expired account', [ + 'account_id' => $account->id, + 'username' => $account->username, + 'expired_at' => $account->expires_at, + ]); + + $this->line(" Suspended: {$account->username} (expired {$account->expires_at->format('M d, Y')})"); + } catch (\Throwable $e) { + Log::error('ProcessExpiredHostingAccounts: Failed to suspend expired account', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + + $this->error(" Failed to suspend {$account->username}: {$e->getMessage()}"); + } + } + } + + private function terminatePastGracePeriod(HostingProvisioningService $provisioning): void + { + $graceDeadline = now()->subMonths(HostingAccount::GRACE_PERIOD_MONTHS); + + $accounts = HostingAccount::query() + ->where('status', HostingAccount::STATUS_SUSPENDED) + ->whereNotNull('expires_at') + ->where('expires_at', '<', $graceDeadline) + ->whereJsonContains('metadata->suspension_origin', 'expiry') + ->with(['node', 'sites']) + ->get(); + + if ($accounts->isEmpty()) { + $this->info('No accounts past grace period to terminate.'); + return; + } + + $this->info("Found {$accounts->count()} account(s) past grace period to terminate."); + + foreach ($accounts as $account) { + try { + $provisioning->terminateAccount($account); + + Log::info('ProcessExpiredHostingAccounts: Terminated account past grace period', [ + 'account_id' => $account->id, + 'username' => $account->username, + 'expired_at' => $account->expires_at, + ]); + + $this->line(" Terminated: {$account->username} (expired {$account->expires_at->format('M d, Y')})"); + } catch (\Throwable $e) { + Log::error('ProcessExpiredHostingAccounts: Failed to terminate account', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + + $this->error(" Failed to terminate {$account->username}: {$e->getMessage()}"); + } + } + } +} diff --git a/app/Console/Commands/ProvisionPendingHostingAccountsCommand.php b/app/Console/Commands/ProvisionPendingHostingAccountsCommand.php new file mode 100644 index 0000000..3187f4d --- /dev/null +++ b/app/Console/Commands/ProvisionPendingHostingAccountsCommand.php @@ -0,0 +1,66 @@ +whereIn('status', ['pending', 'failed']) + ->whereNotNull('hosting_node_id') + ->whereNull('provisioned_at') + ->with(['user:id,name,email', 'product:id,name', 'node:id,name']) + ->get(); + + if ($pendingAccounts->isEmpty()) { + $this->info('No pending hosting accounts found.'); + return self::SUCCESS; + } + + $this->info("Found {$pendingAccounts->count()} pending account(s):"); + $this->newLine(); + + foreach ($pendingAccounts as $account) { + $this->line(" • [{$account->id}] {$account->username} - {$account->user?->name} ({$account->user?->email})"); + $this->line(" Product: {$account->product?->name}, Node: {$account->node?->name}, Status: {$account->status}"); + } + + $this->newLine(); + + if ($this->option('dry-run')) { + $this->warn('Dry run mode - no jobs dispatched.'); + return self::SUCCESS; + } + + if (! $this->confirm('Dispatch provisioning jobs for these accounts?')) { + $this->info('Aborted.'); + return self::SUCCESS; + } + + $dispatched = 0; + foreach ($pendingAccounts as $account) { + // Reset status to pending if it was failed + if ($account->status === 'failed') { + $account->update(['status' => 'pending', 'notes' => null]); + } + + ProvisionHostingAccountJob::dispatch($account->id); + $dispatched++; + $this->info("Dispatched job for account #{$account->id} ({$account->username})"); + } + + $this->newLine(); + $this->info("Dispatched {$dispatched} provisioning job(s). Check queue worker for progress."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecalculateHostingNodeCapacityCommand.php b/app/Console/Commands/RecalculateHostingNodeCapacityCommand.php new file mode 100644 index 0000000..a126738 --- /dev/null +++ b/app/Console/Commands/RecalculateHostingNodeCapacityCommand.php @@ -0,0 +1,41 @@ +when($this->option('node'), fn ($query, $nodeId) => $query->where('id', $nodeId)) + ->when($this->option('shared-only'), fn ($query) => $query->where('type', HostingNode::TYPE_SHARED)) + ->get(); + + foreach ($nodes as $node) { + $node = $capacityService->refreshNodeResourceTotals($node); + $capacityService->checkNode($node); + + $this->line(sprintf( + '%s: accounts=%d allocated=%dGB used=%dGB logical=%.2fGB load=%.2f%%', + $node->name, + $node->current_accounts, + $node->allocated_disk_gb, + $node->used_disk_gb, + $node->logicalCapacityGb(), + (float) $node->current_load_percent + )); + } + + $this->info("Recalculated capacity for {$nodes->count()} node(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RenewSslCertificatesCommand.php b/app/Console/Commands/RenewSslCertificatesCommand.php new file mode 100644 index 0000000..d06c6e7 --- /dev/null +++ b/app/Console/Commands/RenewSslCertificatesCommand.php @@ -0,0 +1,48 @@ +option('force'); + $dryRun = (bool) $this->option('dry-run'); + + if ($dryRun) { + $stats = $renewalService->renewAll($force, true); + $this->info('SSL renewal dry run:'); + $this->line(" Shared hosting nodes with SSL: {$stats['nodes']}"); + $this->line(' Central web server renewal: ' . ($stats['central'] ? 'yes' : 'no')); + $this->line(" Managed VPS orders to queue: {$stats['server_orders']}"); + + return self::SUCCESS; + } + + $this->info('Renewing SSL certificates...'); + $stats = $renewalService->renewAll($force, false); + + $this->info("Renewed on {$stats['nodes']} shared hosting node(s)."); + if ($stats['central']) { + $this->info('Central web server certificates renewed.'); + } + if ($stats['server_orders'] > 0) { + $this->info("Queued ssl.renew for {$stats['server_orders']} managed server(s)."); + } + $this->line("Synced expiry for {$stats['sites_synced']} hosted site(s) and {$stats['domains_synced']} domain record(s)."); + if ($stats['fallbacks'] > 0) { + $this->warn("Re-issued {$stats['fallbacks']} certificate(s) via fallback provisioning."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RepairSslCommand.php b/app/Console/Commands/RepairSslCommand.php new file mode 100644 index 0000000..6708df4 --- /dev/null +++ b/app/Console/Commands/RepairSslCommand.php @@ -0,0 +1,59 @@ +argument('domain'); + $all = $this->option('all'); + + if (!$domain && !$all) { + $this->error('Please specify a domain or use --all flag'); + return 1; + } + + $query = HostedSite::query()->where('ssl_enabled', true); + + if ($domain) { + $query->where('domain', $domain); + } + + $sites = $query->get(); + + if ($sites->isEmpty()) { + $this->warn('No sites found matching criteria'); + return 0; + } + + $this->info("Found {$sites->count()} site(s) to repair"); + + foreach ($sites as $site) { + $this->line("Processing {$site->domain}..."); + + try { + // Re-request SSL certificate (certbot will reuse existing cert if valid) + $nodeProvider->requestLetsEncryptCertificate($site); + $this->info(" ✓ SSL repaired for {$site->domain}"); + } catch (\Exception $e) { + $this->error(" ✗ Failed for {$site->domain}: " . $e->getMessage()); + } + } + + $this->newLine(); + $this->info('Done!'); + + return 0; + } +} diff --git a/app/Console/Commands/ReprovisionHostingAccount.php b/app/Console/Commands/ReprovisionHostingAccount.php new file mode 100644 index 0000000..04b94cb --- /dev/null +++ b/app/Console/Commands/ReprovisionHostingAccount.php @@ -0,0 +1,62 @@ +find($this->argument('account_id')); + + if (!$account) { + $this->error('Account not found'); + return 1; + } + + if (!$account->node) { + $this->error('Account has no hosting node assigned'); + return 1; + } + + $this->info("Re-provisioning account: {$account->username} on node {$account->node->hostname}"); + + try { + $result = $provider->createAccount($account); + + $account->update([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'home_directory' => $result['home_directory'], + 'provisioned_at' => now(), + ]); + + $this->info("Account provisioned successfully!"); + $this->info("Username: {$result['username']}"); + $this->info("Password: {$result['password']}"); + $this->info("Home Directory: {$result['home_directory']}"); + + // Also provision any existing sites + foreach ($account->sites as $site) { + $this->info("Provisioning site: {$site->domain}"); + try { + $provider->addSite($site); + $site->update(['status' => 'active']); + $this->info(" - Site provisioned"); + } catch (\Exception $e) { + $this->warn(" - Failed: {$e->getMessage()}"); + } + } + + return 0; + } catch (\Exception $e) { + $this->error("Failed to provision: {$e->getMessage()}"); + return 1; + } + } +} diff --git a/app/Console/Commands/RetryPendingHostingFulfillmentCommand.php b/app/Console/Commands/RetryPendingHostingFulfillmentCommand.php new file mode 100644 index 0000000..ba0dc5b --- /dev/null +++ b/app/Console/Commands/RetryPendingHostingFulfillmentCommand.php @@ -0,0 +1,43 @@ +option('limit')); + + $orders = CustomerHostingOrder::query() + ->where('status', CustomerHostingOrder::STATUS_PENDING_APPROVAL) + ->whereNotNull('meta->provisioning_hold') + ->with('product') + ->latest('id') + ->limit($limit) + ->get(); + + $queued = 0; + + foreach ($orders as $order) { + $order->update([ + 'status' => CustomerHostingOrder::STATUS_APPROVED, + 'approved_at' => now(), + ]); + ProvisionHostingOrderJob::dispatch($order->fresh()); + $queued++; + $this->line("Queued provisioning for order #{$order->id} ({$order->domain_name})"); + } + + $this->info("Queued {$queued} order(s) for reprovisioning."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RunHostingTerminalWorkerCommand.php b/app/Console/Commands/RunHostingTerminalWorkerCommand.php new file mode 100644 index 0000000..8c13e22 --- /dev/null +++ b/app/Console/Commands/RunHostingTerminalWorkerCommand.php @@ -0,0 +1,217 @@ +argument('session'); + $shell = null; + $status = 'closed'; + $error = null; + $eventOffset = 0; + + try { + $metadata = $sessionManager->readWorkerMetadata($sessionId); + $account = HostingAccount::query() + ->with('node') + ->find($metadata['account_id'] ?? null); + + if (! $account || ! $account->node) { + throw new \RuntimeException('Hosting account or node was not found for this terminal session.'); + } + + $shell = $this->makeShell($account, $metadata, $provider); + $shell->boot(); + + $metadata['status'] = 'running'; + $metadata['error'] = null; + $sessionManager->writeWorkerMetadata($sessionId, $metadata); + $status = 'closed'; + + while (true) { + $metadata = $sessionManager->readWorkerMetadata($sessionId); + + if ($sessionManager->isSessionIdle($metadata)) { + $sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal disconnected after inactivity]\r\n"); + break; + } + + foreach ($sessionManager->readWorkerEvents($sessionId, $eventOffset) as $event) { + $type = (string) ($event['type'] ?? ''); + + if ($type === 'input') { + $shell->write((string) ($event['data'] ?? '')); + continue; + } + + if ($type === 'resize') { + $shell->resize((int) ($event['cols'] ?? 120), (int) ($event['rows'] ?? 30)); + continue; + } + + if ($type === 'close') { + $sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal closed]\r\n"); + break 2; + } + } + + $output = $shell->read(); + if ($output !== '') { + $sessionManager->appendWorkerOutput($sessionId, $output); + } + + if (! $shell->isAlive()) { + break; + } + + usleep(50000); + } + } catch (\Throwable $e) { + $status = 'failed'; + $error = $e->getMessage(); + Log::error('Hosting terminal worker failed.', [ + 'session_id' => $sessionId, + 'exception' => get_class($e), + 'message' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + $sessionManager->appendWorkerOutput( + $sessionId, + sprintf( + "\r\n[Terminal error] %s (%s:%d)\r\n", + $error, + basename($e->getFile()), + $e->getLine() + ) + ); + } finally { + if ($shell instanceof ShellSession) { + $shell->close(); + } + + try { + $metadata = $sessionManager->readWorkerMetadata($sessionId); + $metadata['status'] = $status; + $metadata['error'] = $error; + $metadata['exit_code'] = $status === 'failed' ? 1 : 0; + $metadata['closed_at'] = now()->toIso8601String(); + $sessionManager->writeWorkerMetadata($sessionId, $metadata); + } catch (\Throwable) { + // Ignore metadata write failures during shutdown. + } + } + + return $status === 'failed' ? self::FAILURE : self::SUCCESS; + } + + private function makeShell(HostingAccount $account, array $metadata, SharedNodeProvider $provider): ShellSession + { + $relativePath = $this->normalizeRelativePath((string) ($metadata['relative_path'] ?? '/public_html')); + $cols = max(40, min((int) ($metadata['cols'] ?? 120), 240)); + $rows = max(10, min((int) ($metadata['rows'] ?? 30), 80)); + $launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows); + + if ($this->shouldUseLocalExecution($account->node)) { + return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand)); + } + + $ssh = $provider->connect($account->node); + + if (! $ssh) { + throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.'); + } + + $remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand); + + return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows); + } + + private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string + { + $homeDirectory = "/home/{$account->username}"; + $workingDirectory = $relativePath === '/' + ? $homeDirectory + : $homeDirectory.$relativePath; + + return implode('; ', [ + 'export HOME='.escapeshellarg($homeDirectory), + 'export USER='.escapeshellarg($account->username), + 'export LOGNAME='.escapeshellarg($account->username), + 'export SHELL=/bin/bash', + 'export TERM=xterm-256color', + 'export COLORTERM=truecolor', + 'export COLUMNS='.(int) $cols, + 'export LINES='.(int) $rows, + 'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '), + 'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory), + 'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true', + 'exec /bin/bash --noprofile --norc -i', + ]); + } + + private function buildLocalLaunchCommand(HostingAccount $account, string $launchCommand): string + { + // For local development, fall back to running as current user if user switching isn't available + $devFallback = app()->environment('local') + ? 'exec bash -lc "$LAUNCH_CMD";' + : 'echo '.escapeshellarg('Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.').'; exit 1;'; + + return implode(' ', [ + 'TARGET_USER='.escapeshellarg($account->username).';', + 'LAUNCH_CMD='.escapeshellarg($launchCommand).';', + 'if [ "$(id -un)" = "$TARGET_USER" ]; then exec bash -lc "$LAUNCH_CMD"; fi;', + 'if command -v sudo >/dev/null 2>&1 && sudo -n -u "$TARGET_USER" -- true >/dev/null 2>&1; then exec sudo -n -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;', + 'if command -v runuser >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then exec runuser -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;', + $devFallback, + ]); + } + + private function normalizeRelativePath(string $path): string + { + $path = '/'.ltrim(trim($path), '/'); + $path = preg_replace('#/+#', '/', $path) ?: '/public_html'; + + $segments = []; + foreach (explode('/', $path) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + array_pop($segments); + continue; + } + + $segments[] = $segment; + } + + $normalized = '/'.implode('/', $segments); + + return $normalized === '/' ? '/' : $normalized; + } + + private function shouldUseLocalExecution(HostingNode $node): bool + { + return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost') + && blank($node->ssh_private_key); + } +} diff --git a/app/Console/Commands/SetupNodeSshKey.php b/app/Console/Commands/SetupNodeSshKey.php new file mode 100644 index 0000000..e7aed9c --- /dev/null +++ b/app/Console/Commands/SetupNodeSshKey.php @@ -0,0 +1,227 @@ +argument('node_id')); + + if (!$node) { + $this->error("Hosting node #{$this->argument('node_id')} not found."); + return self::FAILURE; + } + + $this->info("Node: {$node->name} ({$node->ip_address})"); + + if ($this->option('generate')) { + return $this->generateAndInstallKey($node); + } + + if ($this->option('key-path')) { + return $this->installExistingKey($node, $this->option('key-path')); + } + + // Try common key locations + $commonPaths = [ + '/root/.ssh/id_ed25519', + '/root/.ssh/id_rsa', + getenv('HOME') . '/.ssh/id_ed25519', + getenv('HOME') . '/.ssh/id_rsa', + ]; + + foreach ($commonPaths as $path) { + if (file_exists($path) && is_readable($path)) { + $this->info("Found existing key at: {$path}"); + if ($this->confirm("Use this key?", true)) { + return $this->installExistingKey($node, $path); + } + } + } + + $this->warn("No SSH key found. Use --generate to create one, or --key-path to specify an existing key."); + $this->line(""); + $this->line("Examples:"); + $this->line(" php artisan hosting:setup-node-ssh {$node->id} --generate"); + $this->line(" php artisan hosting:setup-node-ssh {$node->id} --key-path=/root/.ssh/id_rsa"); + + return self::FAILURE; + } + + private function generateAndInstallKey(HostingNode $node): int + { + $keyDir = storage_path('app/ssh-keys'); + if (!is_dir($keyDir)) { + mkdir($keyDir, 0700, true); + } + + $keyPath = "{$keyDir}/node_{$node->id}_ed25519"; + + if (file_exists($keyPath)) { + if (!$this->confirm("Key already exists at {$keyPath}. Overwrite?", false)) { + return self::FAILURE; + } + unlink($keyPath); + if (file_exists("{$keyPath}.pub")) { + unlink("{$keyPath}.pub"); + } + } + + // Generate Ed25519 key pair + $this->info("Generating Ed25519 key pair..."); + $result = shell_exec("ssh-keygen -t ed25519 -f " . escapeshellarg($keyPath) . " -N '' -C 'ladill-node-{$node->id}' 2>&1"); + + if (!file_exists($keyPath) || !file_exists("{$keyPath}.pub")) { + $this->error("Failed to generate key pair: {$result}"); + return self::FAILURE; + } + + chmod($keyPath, 0600); + + $privateKey = file_get_contents($keyPath); + $publicKey = trim(file_get_contents("{$keyPath}.pub")); + + // Store private key in the database + $node->update(['ssh_private_key' => $privateKey]); + $this->info("Private key stored in database for node #{$node->id}."); + + // Show instructions for installing the public key + $this->line(""); + $this->warn("=== IMPORTANT: Install the public key on the node ==="); + $this->line(""); + + $isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local'; + + if ($isLocal) { + $this->info("This is a local node. Installing public key automatically..."); + $authorizedKeysFile = '/root/.ssh/authorized_keys'; + + if (!is_dir('/root/.ssh')) { + mkdir('/root/.ssh', 0700, true); + } + + // Check if key already installed + $existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : ''; + if (str_contains($existingKeys, $publicKey)) { + $this->info("Public key already installed in {$authorizedKeysFile}"); + } else { + file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND); + chmod($authorizedKeysFile, 0600); + $this->info("Public key installed in {$authorizedKeysFile}"); + } + } else { + $this->line("Run this on the node ({$node->ip_address}):"); + $this->line(""); + $this->line(" mkdir -p /root/.ssh && chmod 700 /root/.ssh"); + $this->line(" echo '{$publicKey}' >> /root/.ssh/authorized_keys"); + $this->line(" chmod 600 /root/.ssh/authorized_keys"); + $this->line(""); + } + + // Test the connection + $this->info("Testing SSH connection..."); + return $this->testConnection($node); + } + + private function installExistingKey(HostingNode $node, string $keyPath): int + { + if (!file_exists($keyPath)) { + $this->error("Key file not found: {$keyPath}"); + return self::FAILURE; + } + + $privateKey = file_get_contents($keyPath); + + if (!str_contains($privateKey, '-----BEGIN') && !str_contains($privateKey, 'PRIVATE KEY')) { + $this->error("File does not appear to be a valid SSH private key."); + return self::FAILURE; + } + + $node->update(['ssh_private_key' => $privateKey]); + $this->info("Private key stored in database for node #{$node->id}."); + + // Check if public key needs to be installed + $pubKeyPath = "{$keyPath}.pub"; + if (file_exists($pubKeyPath)) { + $publicKey = trim(file_get_contents($pubKeyPath)); + $isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local'; + + if ($isLocal) { + $authorizedKeysFile = '/root/.ssh/authorized_keys'; + $existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : ''; + if (!str_contains($existingKeys, $publicKey)) { + $this->info("Installing public key on local node..."); + if (!is_dir('/root/.ssh')) { + mkdir('/root/.ssh', 0700, true); + } + file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND); + chmod($authorizedKeysFile, 0600); + } + } + } + + $this->info("Testing SSH connection..."); + return $this->testConnection($node); + } + + private function testConnection(HostingNode $node): int + { + try { + $node->refresh(); + $provider = app(\App\Services\Hosting\Providers\SharedNodeProvider::class); + + // Use reflection to call private connect method for testing + $reflection = new \ReflectionClass($provider); + $method = $reflection->getMethod('connect'); + $method->setAccessible(true); + $ssh = $method->invoke($provider, $node); + + if ($ssh === null) { + $this->warn("Node is using local execution (no SSH). This means the SSH key is not configured properly."); + return self::FAILURE; + } + + $output = $ssh->exec('whoami'); + $this->info("SSH connection successful! Connected as: " . trim($output)); + + // Verify mysql access + $mysqlTest = $ssh->exec('mysql -e "SELECT 1" 2>&1'); + if (str_contains($mysqlTest, '1')) { + $this->info("MySQL access via auth_socket: OK"); + } else { + $this->warn("MySQL access via auth_socket: FAILED ({$mysqlTest})"); + $this->warn("MySQL may not be installed or auth_socket may not be configured for root."); + } + + // Verify runuser access + $runuserTest = $ssh->exec('runuser -u nobody -- whoami 2>&1'); + if (trim($runuserTest) === 'nobody') { + $this->info("runuser access: OK"); + } else { + $this->warn("runuser access: FAILED"); + } + + $ssh->disconnect(); + $this->info(""); + $this->info("Node #{$node->id} is fully configured for SSH access."); + return self::SUCCESS; + + } catch (\Throwable $e) { + $this->error("SSH connection failed: " . $e->getMessage()); + $this->line(""); + $this->line("Ensure:"); + $this->line(" 1. SSH is running on the node ({$node->ip_address}:{$node->ssh_port})"); + $this->line(" 2. Root login is permitted (PermitRootLogin yes in sshd_config)"); + $this->line(" 3. The public key is installed in /root/.ssh/authorized_keys"); + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/SyncHostingAccountUsageCommand.php b/app/Console/Commands/SyncHostingAccountUsageCommand.php new file mode 100644 index 0000000..4914dba --- /dev/null +++ b/app/Console/Commands/SyncHostingAccountUsageCommand.php @@ -0,0 +1,91 @@ +with(['product', 'node', 'databases', 'user', 'latestOrder']) + ->where('type', 'shared') + ->whereIn('status', ['active', 'provisioning', 'suspended']) + ->when($this->option('account'), fn ($query, $accountId) => $query->where('id', $accountId)) + ->get(); + + $touchedNodeIds = []; + + foreach ($accounts as $account) { + if (! $account->node) { + continue; + } + + try { + $usage = $sharedNodeProvider->getUsageStats($account); + $requestedDiskGb = $account->requestedDiskGb(); + + $account->update([ + 'allocated_disk_gb' => $requestedDiskGb, + 'disk_used_bytes' => (int) ($usage['disk_used_bytes'] ?? $account->disk_used_bytes), + 'inode_count' => (int) ($usage['inode_count'] ?? $account->inode_count), + 'cpu_usage_percent' => $usage['cpu_usage_percent'] ?? $account->cpu_usage_percent, + 'memory_used_mb' => (int) ($usage['memory_used_mb'] ?? $account->memory_used_mb), + 'process_count' => (int) ($usage['process_count'] ?? $account->process_count), + 'io_usage_mb' => $usage['io_usage_mb'] ?? $account->io_usage_mb, + 'last_usage_sync_at' => now(), + ]); + + $resourcePolicyService->process($account->fresh(['product', 'node', 'databases', 'user', 'latestOrder'])); + $touchedNodeIds[$account->hosting_node_id] = true; + + $this->line(sprintf( + '%s: disk=%dGB inodes=%d cpu=%s%% mem=%dMB proc=%d', + $account->username, + (int) ceil(((int) ($usage['disk_used_bytes'] ?? 0)) / self::BYTES_PER_GB), + (int) ($usage['inode_count'] ?? 0), + $usage['cpu_usage_percent'] ?? 'n/a', + (int) ($usage['memory_used_mb'] ?? 0), + (int) ($usage['process_count'] ?? 0) + )); + } catch (\Throwable $e) { + Log::warning('Failed to sync hosting account usage.', [ + 'hosting_account_id' => $account->id, + 'username' => $account->username, + 'error' => $e->getMessage(), + ]); + + $this->warn("Failed to sync {$account->username}: {$e->getMessage()}"); + } + } + + foreach (array_keys($touchedNodeIds) as $nodeId) { + $node = HostingNode::query()->find($nodeId); + + if ($node) { + $capacityService->refreshNodeResourceTotals($node); + $capacityService->checkNode($node); + } + } + + $this->info("Synced {$accounts->count()} hosting account(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/ContaboApiException.php b/app/Exceptions/ContaboApiException.php new file mode 100644 index 0000000..e0ed30b --- /dev/null +++ b/app/Exceptions/ContaboApiException.php @@ -0,0 +1,58 @@ +|null $payload + */ + public function __construct( + public readonly int $httpStatus, + public readonly ?array $payload, + string $message, + ) { + parent::__construct($message); + } + + public static function fromResponse(int $httpStatus, string $body): self + { + $payload = json_decode($body, true); + $decoded = is_array($payload) ? $payload : null; + + $apiMessage = ''; + if (is_array($decoded)) { + $apiMessage = (string) ($decoded['message'] ?? $decoded['error'] ?? $decoded['statusCode']['message'] ?? ''); + } + + $message = $apiMessage !== '' + ? $apiMessage + : "Contabo API error (HTTP {$httpStatus})"; + + return new self($httpStatus, $decoded, $message); + } + + public function isPaymentRequired(): bool + { + if ($this->httpStatus === 402) { + return true; + } + + return self::messageIndicatesPaymentRequired($this->getMessage()) + || self::messageIndicatesPaymentRequired(json_encode($this->payload) ?: ''); + } + + public static function messageIndicatesPaymentRequired(string $text): bool + { + if ($text === '') { + return false; + } + + return (bool) preg_match( + '/\b402\b|payment\s+required|insufficient\s+(?:account\s+)?balance|not\s+enough\s+(?:credit|funds)|account\s+balance|additional\s+pay(?:ed|ment)\s+service|out\s+of\s+funds/i', + $text + ); + } +} diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php new file mode 100644 index 0000000..938d703 --- /dev/null +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -0,0 +1,140 @@ +route('servers.dashboard'); + } + + $verifier = Str::random(64); + $state = Str::random(40); + $request->session()->put('sso.verifier', $verifier); + $request->session()->put('sso.state', $state); + $request->session()->put('sso.intended', $request->query('redirect', route('servers.dashboard'))); + + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + $query = http_build_query([ + 'response_type' => 'code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'scope' => 'openid profile email', + 'state' => $state, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]); + + return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.$query); + } + + public function callback(Request $request): RedirectResponse + { + $authLogin = 'https://'.config('app.auth_domain').'/login'; + + if ($request->filled('error') || ! $request->filled('code') + || $request->query('state') !== $request->session()->pull('sso.state')) { + return redirect()->away($authLogin); + } + + $issuer = rtrim((string) config('services.ladill_sso.issuer'), '/'); + + $tokenRes = Http::asForm()->post($issuer.'/oauth/token', [ + 'grant_type' => 'authorization_code', + 'client_id' => (string) config('services.ladill_sso.client_id'), + 'client_secret' => (string) config('services.ladill_sso.client_secret'), + 'redirect_uri' => (string) config('services.ladill_sso.redirect'), + 'code' => (string) $request->query('code'), + 'code_verifier' => (string) $request->session()->pull('sso.verifier'), + ]); + if ($tokenRes->failed()) { + return redirect()->away($authLogin); + } + + $claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo'); + if ($claims->failed() || ! $claims->json('sub')) { + return redirect()->away($authLogin); + } + + $user = User::updateOrCreate( + ['public_id' => (string) $claims->json('sub')], + [ + 'name' => $claims->json('name'), + 'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com', + 'avatar_url' => $claims->json('picture'), + ], + ); + + // Accept any pending team invites for this email on first sign-in. + \App\Models\HostingTeamMember::linkPendingInvitesFor($user); + + Auth::login($user, remember: true); + $request->session()->regenerate(); + $request->session()->forget('mailbox_link_banner_dismissed'); + + return redirect()->intended($request->session()->pull('sso.intended', route('servers.dashboard'))); + } + + public function logout(Request $request): RedirectResponse + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + $home = 'https://'.config('app.platform_domain'); + $endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home); + + return redirect()->away($endSession); + } + + public function frontchannelLogout(Request $request): Response|RedirectResponse + { + if ($this->shouldLogoutForMailbox($request)) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + // SLO chain: if the central sequencer passed a (ladill.com) return URL, + // continue the top-level redirect chain to the next app; else acknowledge. + $return = (string) $request->query('return', ''); + $root = (string) config('app.platform_domain', 'ladill.com'); + $host = parse_url($return, PHP_URL_HOST); + if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) { + return redirect()->away($return); + } + + return response('', 204); + } + + private function shouldLogoutForMailbox(Request $request): bool + { + $mailbox = strtolower(trim((string) $request->query('mailbox', ''))); + if ($mailbox === '' || ! str_contains($mailbox, '@')) { + return true; + } + + $user = Auth::user(); + if (! $user) { + return false; + } + + return strtolower((string) $user->email) !== $mailbox; + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..e7f7c94 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +billingSnapshot($account?->public_id); + + return view('email.account.wallet', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'topupUrl' => $this->topupUrl(), + ]); + } + + /** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */ + public function billing(): View + { + $account = ladill_account(); + [$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id); + + $paidMailboxes = []; + $monthlyTotalMinor = 0; + try { + foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) { + if (! ($m['is_paid'] ?? false)) { + continue; + } + $m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0)); + $m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0)); + $monthlyTotalMinor += $m['price_minor']; + $paidMailboxes[] = $m; + } + } catch (\Throwable $e) { + report($e); + } + + return view('email.account.billing', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'paidMailboxes' => $paidMailboxes, + 'monthlyTotalMinor' => $monthlyTotalMinor, + 'topupUrl' => $this->topupUrl(), + ]); + } + + public function settings(): View + { + $account = ladill_account(); + $settings = EmailSetting::firstOrNew(['user_id' => $account?->id]); + ['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account); + + return view('email.account.settings', [ + 'account' => $account, + 'settings' => $settings, + 'linkStatus' => $linkStatus, + 'mailboxOptions' => $mailboxOptions, + 'showMailboxLinkUi' => $showMailboxLinkUi, + ]); + } + + public function linkMailbox(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'mailbox_address' => ['required', 'email', 'max:255'], + ]); + + try { + $result = $this->identity->linkMailbox( + (string) $account->public_id, + $data['mailbox_address'], + ); + } catch (\Illuminate\Http\Client\RequestException $e) { + $message = (string) $e->response?->json('message', ''); + if ($message !== '') { + return redirect()->route('account.settings')->with('error', $message); + } + + report($e); + + return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.'); + } catch (\Throwable $e) { + report($e); + + return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.'); + } + + $request->session()->forget('mailbox_link_banner_dismissed'); + + $connectUrl = (string) ($result['webmail_connect_url'] ?? ''); + if ($connectUrl === '') { + $connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect'; + } + + return redirect()->away($connectUrl); + } + + public function unlinkMailbox(Request $request): RedirectResponse + { + $account = ladill_account(); + + try { + $this->identity->unlinkMailbox((string) $account->public_id); + } catch (\Illuminate\Http\Client\RequestException $e) { + $message = (string) $e->response?->json('message', ''); + if ($message !== '') { + return redirect()->route('account.settings')->with('error', $message); + } + + report($e); + + return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.'); + } catch (\Throwable $e) { + report($e); + + return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.'); + } + + $request->session()->forget('mailbox_link_banner_dismissed'); + + return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.'); + } + + /** @return array{linkStatus: array, mailboxOptions: array>, showMailboxLinkUi: bool} */ + private function mailboxLinkContext(?User $account): array + { + $linkStatus = ['show_reminder' => false]; + $mailboxOptions = []; + + if (! $account?->public_id) { + return [ + 'linkStatus' => $linkStatus, + 'mailboxOptions' => $mailboxOptions, + 'showMailboxLinkUi' => false, + ]; + } + + try { + $mailboxOptions = $this->mailboxes->forUser((string) $account->public_id); + } catch (\Throwable $e) { + report($e); + } + + try { + $linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id); + } catch (\Throwable $e) { + report($e); + $linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions); + } + + $showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null) + || ($linkStatus['show_reminder'] ?? false) + || $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus); + + return [ + 'linkStatus' => $linkStatus, + 'mailboxOptions' => $mailboxOptions, + 'showMailboxLinkUi' => $showMailboxLinkUi, + ]; + } + + /** @param array> $mailboxOptions */ + private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool + { + if ($linkStatus['linked_mailbox'] ?? null) { + return false; + } + + $email = strtolower(trim($account->email ?? '')); + if ($email === '') { + return false; + } + + foreach ($mailboxOptions as $mailbox) { + if (strtolower((string) ($mailbox['address'] ?? '')) === $email) { + return false; + } + } + + return count($mailboxOptions) > 0; + } + + /** @param array> $mailboxOptions */ + /** @return array */ + private function localMailboxLinkStatus(User $account, array $mailboxOptions): array + { + $needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []); + + return [ + 'show_reminder' => $needsLink, + 'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox', + 'linked_mailbox' => null, + 'account_email' => $account->email, + ]; + } + + public function updateSettings(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'], + 'notify_email' => ['nullable', 'email', 'max:255'], + 'product_updates' => ['nullable', 'boolean'], + ]); + + EmailSetting::updateOrCreate( + ['user_id' => $account->id], + [ + 'default_quota_mb' => $data['default_quota_mb'] ?? null, + 'notify_email' => $data['notify_email'] ?? null, + 'product_updates' => $request->boolean('product_updates'), + ], + ); + + return redirect()->route('account.settings')->with('success', 'Settings saved.'); + } + + /** + * @return array{0:int,1:array} [balanceMinor, ledger] + */ + private function billingSnapshot(?string $publicId): array + { + if (! $publicId) { + return [0, []]; + } + + try { + return [ + $this->wallet->balanceMinor(ladill_account()), + $this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')), + ]; + } catch (\Throwable $e) { + report($e); + + return [0, []]; + } + } +} diff --git a/app/Http/Controllers/Email/AfiaController.php b/app/Http/Controllers/Email/AfiaController.php new file mode 100644 index 0000000..fb7fdeb --- /dev/null +++ b/app/Http/Controllers/Email/AfiaController.php @@ -0,0 +1,76 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array Live email context for grounding. */ + private function context(): array + { + $account = ladill_account(); + + if (! $account) { + return ['signed_in' => 'no']; + } + + $pid = (string) $account->public_id; + $ctx = ['signed_in' => 'yes']; + + try { + $mailboxes = app(MailboxClient::class)->forUser($pid); + $ctx['mailboxes_total'] = count($mailboxes); + $ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false))); + } catch (\Throwable) { + } + + try { + $domains = app(EmailDomainClient::class)->forUser($pid); + $ctx['domains_total'] = count($domains); + $ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false))); + } catch (\Throwable) { + } + + try { + $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2); + } catch (\Throwable) { + } + + return $ctx; + } +} diff --git a/app/Http/Controllers/Email/DeveloperController.php b/app/Http/Controllers/Email/DeveloperController.php new file mode 100644 index 0000000..eb1abc9 --- /dev/null +++ b/app/Http/Controllers/Email/DeveloperController.php @@ -0,0 +1,45 @@ + $request->user()->tokens()->latest()->get(), + 'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1', + 'newToken' => session('new_token'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:60'], + ]); + + $token = $request->user()->createToken($data['name'], ['mailboxes:read']); + + return redirect()->route('account.developers') + ->with('new_token', $token->plainTextToken) + ->with('success', 'Token created — copy it now, it won’t be shown again.'); + } + + public function destroy(Request $request, int $token): RedirectResponse + { + $request->user()->tokens()->whereKey($token)->delete(); + + return redirect()->route('account.developers')->with('success', 'Token revoked.'); + } +} diff --git a/app/Http/Controllers/Email/DomainsController.php b/app/Http/Controllers/Email/DomainsController.php new file mode 100644 index 0000000..3fbfd4c --- /dev/null +++ b/app/Http/Controllers/Email/DomainsController.php @@ -0,0 +1,85 @@ +public_id; + } + + public function index(): View + { + $domains = []; + $error = null; + try { + $domains = $this->domains->forUser($this->pid()); + } catch (\Throwable $e) { + report($e); + $error = 'Could not load your domains.'; + } + + return view('email.domains.index', compact('domains', 'error')); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate(['domain' => ['required', 'string', 'max:253']]); + + try { + $domain = $this->domains->create($this->pid(), strtolower(trim($data['domain']))); + + return redirect()->route('email.domains.show', $domain['id']) + ->with('success', 'Domain added — publish the DNS records below, then verify.'); + } catch (\Throwable $e) { + report($e); + + return back()->with('error', 'Could not add that domain. It may already exist.'); + } + } + + public function show(int $id): View|RedirectResponse + { + try { + $domain = $this->domains->show($this->pid(), $id); + } catch (\Throwable $e) { + return redirect()->route('email.domains.index')->with('error', 'Domain not found.'); + } + + return view('email.domains.show', ['domain' => $domain]); + } + + public function verify(int $id): RedirectResponse + { + try { + $domain = $this->domains->verify($this->pid(), $id); + $ok = $domain['active'] ?? false; + + return redirect()->route('email.domains.show', $id) + ->with($ok ? 'success' : 'error', $ok ? 'Domain verified — you can now create mailboxes.' : 'Not verified yet. DNS can take time to propagate.'); + } catch (\Throwable $e) { + return back()->with('error', 'Verification failed. Try again shortly.'); + } + } + + public function destroy(int $id): RedirectResponse + { + try { + $this->domains->delete($this->pid(), $id); + } catch (\Throwable $e) { + return back()->with('error', 'Could not remove that domain.'); + } + + return redirect()->route('email.domains.index')->with('success', 'Domain removed.'); + } +} diff --git a/app/Http/Controllers/Email/MailboxLinkBannerController.php b/app/Http/Controllers/Email/MailboxLinkBannerController.php new file mode 100644 index 0000000..9a219f7 --- /dev/null +++ b/app/Http/Controllers/Email/MailboxLinkBannerController.php @@ -0,0 +1,17 @@ +session()->put('mailbox_link_banner_dismissed', true); + + return back(); + } +} diff --git a/app/Http/Controllers/Email/MailboxesController.php b/app/Http/Controllers/Email/MailboxesController.php new file mode 100644 index 0000000..9fddfad --- /dev/null +++ b/app/Http/Controllers/Email/MailboxesController.php @@ -0,0 +1,254 @@ +public_id; + } + + /** Storage tiers + the default (free) plan. Pricing is purely per-tier. */ + private function quota(): array + { + return [ + 'tiers' => MailboxPricing::tiers(), + 'default_mb' => MailboxPricing::defaultQuotaMb(), + 'currency' => config('email.currency', 'GHS'), + ]; + } + + public function index(Request $request): View + { + $mailboxes = []; + $error = null; + try { + $mailboxes = $this->mailboxes->forUser($this->pid()); + } catch (\Throwable $e) { + report($e); + $error = 'Could not load your mailboxes.'; + } + + $q = trim((string) $request->query('q', '')); + if ($q !== '') { + $needle = mb_strtolower($q); + $mailboxes = array_values(array_filter($mailboxes, fn ($m) => str_contains( + mb_strtolower(($m['address'] ?? '').' '.($m['display_name'] ?? '')), $needle + ))); + } + + return view('email.mailboxes.index', ['mailboxes' => $mailboxes, 'error' => $error, 'q' => $q]); + } + + public function create(): View + { + $domains = []; + try { + $domains = $this->domains->verified($this->pid()); + } catch (\Throwable $e) { + report($e); + } + + return view('email.mailboxes.create', [ + 'domains' => $domains, + 'quota' => $this->quota(), + 'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()), + 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'email_domain_id' => ['required', 'integer'], + 'local_part' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9._%+-]+$/i'], + 'display_name' => ['required', 'string', 'max:160'], + 'password' => ['required', 'string', 'min:8', 'max:190', 'confirmed'], + 'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) { + if (! MailboxPricing::isValidQuota((int) $value)) { + $fail('Choose a valid storage plan.'); + } + }], + ]); + + $user = ladill_account(); + $quotaMb = (int) $data['quota_mb']; + $price = MailboxPricing::priceMinorFor($quotaMb); // 1 GB = 0 (free forever) + $isPaid = $price > 0; + $reference = 'mbx_'.Str::lower(Str::random(20)); + + // Paid tiers are wallet-funded (debit first); insufficient → top up. + if ($isPaid) { + if (! $this->wallet->canAfford($user, $price)) { + return back()->withInput() + ->with('error', 'Your wallet balance is too low for this storage plan. Please top up and try again.') + ->with('topup', true); + } + if (! $this->wallet->charge($user, $price, $reference, 'mailbox_create', 'Mailbox '.strtolower($data['local_part']))) { + return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true); + } + } + + try { + $mailbox = $this->mailboxes->create( + $this->pid(), + (int) $data['email_domain_id'], + strtolower(trim($data['local_part'])), + $data['display_name'], + $data['password'], + $quotaMb, + $isPaid, + $isPaid ? $reference : null, + ); + + return redirect()->route('email.mailboxes.show', $mailbox['id']) + ->with('success', 'Mailbox '.$mailbox['address'].' is being set up.'); + } catch (\Throwable $e) { + // Provisioning failed after a charge — refund so the customer isn't out of pocket. + if ($isPaid) { + $this->wallet->refund($user, $price, $reference, 'Mailbox creation failed'); + } + $msg = $e instanceof \Illuminate\Http\Client\RequestException + ? (string) ($e->response?->json('message') ?? 'Could not create the mailbox.') + : 'Could not create the mailbox.'; + report($e); + + return back()->withInput()->with('error', $msg); + } + } + + public function show(int $id): View|RedirectResponse + { + try { + $mailbox = $this->mailboxes->show($this->pid(), $id); + } catch (\Throwable $e) { + return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.'); + } + + return view('email.mailboxes.show', ['mailbox' => $mailbox]); + } + + /** Upgrade form — pick a larger storage plan for an existing mailbox. */ + public function upgrade(int $id): View|RedirectResponse + { + try { + $mailbox = $this->mailboxes->show($this->pid(), $id); + } catch (\Throwable $e) { + return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.'); + } + + $currentMb = (int) ($mailbox['quota_mb'] ?? 0); + // Only offer plans larger than the current one. + $tiers = array_values(array_filter(MailboxPricing::tiers(), fn ($t) => $t['mb'] > $currentMb)); + + if (empty($tiers)) { + return redirect()->route('email.mailboxes.show', $id)->with('error', 'This mailbox is already on the largest plan.'); + } + + return view('email.mailboxes.upgrade', [ + 'mailbox' => $mailbox, + 'currentMb' => $currentMb, + 'tiers' => $tiers, + 'defaultMb' => $tiers[0]['mb'], + 'currency' => config('email.currency', 'GHS'), + 'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()), + 'topupUrl' => 'https://'.config('app.account_domain').'/wallet', + ]); + } + + /** Apply the upgrade — charge the new tier, then change the quota. */ + public function upgradeStore(Request $request, int $id): RedirectResponse + { + $data = $request->validate([ + 'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) { + if (! MailboxPricing::isValidQuota((int) $value)) { + $fail('Choose a valid storage plan.'); + } + }], + ]); + + $user = ladill_account(); + $newMb = (int) $data['quota_mb']; + + try { + $mailbox = $this->mailboxes->show($this->pid(), $id); + } catch (\Throwable $e) { + return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.'); + } + + if ($newMb <= (int) ($mailbox['quota_mb'] ?? 0)) { + return back()->withInput()->with('error', 'Choose a larger plan than your current one.'); + } + + $price = MailboxPricing::priceMinorFor($newMb); + $isPaid = $price > 0; + $reference = 'mbxup_'.Str::lower(Str::random(20)); + + if ($isPaid) { + if (! $this->wallet->canAfford($user, $price)) { + return back()->withInput() + ->with('error', 'Your wallet balance is too low for this plan. Please top up and try again.') + ->with('topup', true); + } + if (! $this->wallet->charge($user, $price, $reference, 'mailbox_upgrade', 'Upgrade '.($mailbox['address'] ?? 'mailbox').' to '.MailboxPricing::label($newMb))) { + return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true); + } + } + + try { + $this->mailboxes->updateQuota($this->pid(), $id, $newMb, $isPaid, $isPaid ? $reference : null); + } catch (\Throwable $e) { + if ($isPaid) { + $this->wallet->refund($user, $price, $reference, 'Mailbox upgrade failed'); + } + report($e); + + return back()->withInput()->with('error', 'Could not upgrade the mailbox.'); + } + + return redirect()->route('email.mailboxes.show', $id) + ->with('success', 'Upgraded to '.MailboxPricing::label($newMb).'.'); + } + + public function resetPassword(Request $request, int $id): RedirectResponse + { + $data = $request->validate(['password' => ['required', 'string', 'min:8', 'max:190', 'confirmed']]); + + try { + $this->mailboxes->resetPassword($this->pid(), $id, $data['password']); + } catch (\Throwable $e) { + return back()->with('error', 'Could not update the password.'); + } + + return redirect()->route('email.mailboxes.show', $id)->with('success', 'Password updated.'); + } + + public function destroy(int $id): RedirectResponse + { + try { + $this->mailboxes->delete($this->pid(), $id); + } catch (\Throwable $e) { + return back()->with('error', 'Could not delete the mailbox.'); + } + + return redirect()->route('email.mailboxes.index')->with('success', 'Mailbox deleted.'); + } +} diff --git a/app/Http/Controllers/Email/OverviewController.php b/app/Http/Controllers/Email/OverviewController.php new file mode 100644 index 0000000..36c5de0 --- /dev/null +++ b/app/Http/Controllers/Email/OverviewController.php @@ -0,0 +1,41 @@ +public_id; + $mailboxes = []; + $domains = []; + $error = null; + + try { + $mailboxes = $this->mailboxes->forUser($pid); + $domains = $this->domains->forUser($pid); + } catch (\Throwable $e) { + report($e); + $error = 'Some data could not be loaded right now.'; + } + + return view('email.dashboard', [ + 'mailboxCount' => count($mailboxes), + 'domainCount' => count($domains), + 'verifiedDomainCount' => collect($domains)->where('active', true)->count(), + 'recent' => collect($mailboxes)->take(5)->all(), + 'error' => $error, + ]); + } +} diff --git a/app/Http/Controllers/Email/TeamController.php b/app/Http/Controllers/Email/TeamController.php new file mode 100644 index 0000000..67da548 --- /dev/null +++ b/app/Http/Controllers/Email/TeamController.php @@ -0,0 +1,114 @@ +where('account_id', $account->id) + ->with('member') + ->orderBy('status')->orderBy('email') + ->get(); + + return view('email.account.team', [ + 'account' => $account, + 'members' => $members, + 'canManage' => $this->canManage($request), + 'isOwner' => $request->user()->id === $account->id, + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManage($request), 403); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:admin,member'], + ]); + + $account = ladill_account(); + $email = strtolower($validated['email']); + + if ($email === strtolower($account->email)) { + return back()->withErrors(['email' => 'The owner is already on the account.']); + } + + $member = EmailTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]); + $member->role = $validated['role']; + if (! $member->exists) { + $member->status = EmailTeamMember::STATUS_INVITED; + $member->token = Str::random(40); + } + $member->save(); + + if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) { + EmailTeamMember::linkPendingInvitesFor($existing); + } + + return back()->with('success', $email.' invited.'); + } + + public function updateRole(Request $request, EmailTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $validated = $request->validate(['role' => ['required', 'in:admin,member']]); + $member->update(['role' => $validated['role']]); + + return back()->with('success', 'Role updated.'); + } + + public function destroy(Request $request, EmailTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $member->delete(); + + return back()->with('success', 'Member removed.'); + } + + /** Switch the acting account (own, or one you're a member of). */ + public function switchAccount(Request $request): RedirectResponse + { + $validated = $request->validate(['account' => ['required', 'integer']]); + + abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403); + + $request->session()->put('ladill_account', (int) $validated['account']); + + return redirect()->route('email.dashboard'); + } + + private function canManage(Request $request): bool + { + $user = $request->user(); + $account = ladill_account(); + + if ($user->id === $account->id) { + return true; // owner + } + + return $user->memberships() + ->where('account_id', $account->id) + ->where('role', EmailTeamMember::ROLE_ADMIN) + ->exists(); + } +} diff --git a/app/Http/Controllers/Hosting/AccountController.php b/app/Http/Controllers/Hosting/AccountController.php new file mode 100644 index 0000000..270858c --- /dev/null +++ b/app/Http/Controllers/Hosting/AccountController.php @@ -0,0 +1,106 @@ +billingSnapshot($account?->public_id); + + return view('hosting.account.wallet', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'topupUrl' => $this->topupUrl(), + ]); + } + + public function billing(): View + { + $account = ladill_account(); + [$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id); + + return view('hosting.account.billing', [ + 'balanceMinor' => $balanceMinor, + 'spentMinor' => (int) ($ledger['spent_minor'] ?? 0), + 'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0), + 'topupUrl' => $this->topupUrl(), + ]); + } + + public function settings(): View + { + $account = ladill_account(); + $settings = HostingSetting::firstOrNew(['user_id' => $account?->id]); + + return view('hosting.account.settings', [ + 'account' => $account, + 'settings' => $settings, + ]); + } + + public function updateSettings(Request $request): RedirectResponse + { + $account = ladill_account(); + + $data = $request->validate([ + 'notify_email' => ['nullable', 'email', 'max:255'], + 'product_updates' => ['nullable', 'boolean'], + ]); + + HostingSetting::updateOrCreate( + ['user_id' => $account->id], + [ + 'notify_email' => $data['notify_email'] ?? null, + 'product_updates' => $request->boolean('product_updates'), + ], + ); + + return redirect()->route('account.settings')->with('success', 'Settings saved.'); + } + + /** + * @return array{0:int,1:array} [balanceMinor, ledger] + */ + private function billingSnapshot(?string $publicId): array + { + if (! $publicId) { + return [0, []]; + } + + try { + return [ + $this->wallet->balanceMinor(ladill_account()), + $this->billing->serviceLedger($publicId, (string) config('billing.service', 'hosting')), + ]; + } catch (\Throwable $e) { + report($e); + + return [0, []]; + } + } +} diff --git a/app/Http/Controllers/Hosting/AfiaController.php b/app/Http/Controllers/Hosting/AfiaController.php new file mode 100644 index 0000000..499e37d --- /dev/null +++ b/app/Http/Controllers/Hosting/AfiaController.php @@ -0,0 +1,101 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context()); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(): array + { + $user = auth()->user(); + + if (! $user) { + return ['signed_in' => 'no']; + } + + $sharedTypes = [ + HostingProduct::TYPE_SINGLE_DOMAIN, + HostingProduct::TYPE_MULTI_DOMAIN, + HostingProduct::TYPE_WORDPRESS, + ]; + + $sharedAccountIds = HostingAccountMember::query() + ->where('user_id', $user->id) + ->pluck('hosting_account_id'); + + $accounts = HostingAccount::query() + ->where(function ($query) use ($user, $sharedAccountIds) { + $query->where('user_id', $user->id); + if ($sharedAccountIds->isNotEmpty()) { + $query->orWhereIn('id', $sharedAccountIds); + } + }) + ->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes)) + ->with('sites') + ->get(); + + $ctx = [ + 'signed_in' => 'yes', + 'hosting_accounts_total' => $accounts->count(), + 'hosting_accounts_active' => $accounts->where('status', HostingAccount::STATUS_ACTIVE)->count(), + 'linked_domains' => HostedSite::query() + ->whereIn('hosting_account_id', $accounts->pluck('id')) + ->count(), + 'pending_orders' => CustomerHostingOrder::query() + ->forUser($user->id) + ->whereIn('status', [ + CustomerHostingOrder::STATUS_PENDING_PAYMENT, + CustomerHostingOrder::STATUS_PENDING_APPROVAL, + CustomerHostingOrder::STATUS_APPROVED, + CustomerHostingOrder::STATUS_PROVISIONING, + ]) + ->count(), + ]; + + $account = ladill_account(); + if ($account?->public_id) { + try { + $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); + } catch (\Throwable) { + } + } + + return $ctx; + } +} diff --git a/app/Http/Controllers/Hosting/DeveloperController.php b/app/Http/Controllers/Hosting/DeveloperController.php new file mode 100644 index 0000000..7ff33a9 --- /dev/null +++ b/app/Http/Controllers/Hosting/DeveloperController.php @@ -0,0 +1,45 @@ + $request->user()->tokens()->latest()->get(), + 'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1', + 'newToken' => session('new_token'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:60'], + ]); + + $token = $request->user()->createToken($data['name'], ['mailboxes:read']); + + return redirect()->route('account.developers') + ->with('new_token', $token->plainTextToken) + ->with('success', 'Token created — copy it now, it won’t be shown again.'); + } + + public function destroy(Request $request, int $token): RedirectResponse + { + $request->user()->tokens()->whereKey($token)->delete(); + + return redirect()->route('account.developers')->with('success', 'Token revoked.'); + } +} diff --git a/app/Http/Controllers/Hosting/HostingController.php b/app/Http/Controllers/Hosting/HostingController.php new file mode 100644 index 0000000..16c55c3 --- /dev/null +++ b/app/Http/Controllers/Hosting/HostingController.php @@ -0,0 +1,278 @@ +route('hosting.single-domain'); + } + + public function show( + Request $request, + HostingOrder $hostingOrder, + ResellerClubHostingService $hosting, + ResellerClubProductService $products + ): View + { + if ((int) $hostingOrder->user_id !== (int) $request->user()->id) { + abort(403); + } + + $remoteDetails = null; + if ($hostingOrder->rc_order_id && $hosting->isConfigured()) { + $result = $hosting->getOrderDetails($hostingOrder->rc_order_id); + if ($result['success'] ?? false) { + $remoteDetails = $result['order']; + // Sync latest data + $hosting->syncOrderToLocal($result['order']['raw'] ?? $result['order'], $request->user()->id, $hostingOrder->domain_id); + $hostingOrder->refresh(); + } + } + + return view('hosting.show', [ + 'order' => $hostingOrder, + 'remoteDetails' => $remoteDetails, + 'renewalOptions' => $this->renewalOptionsForOrder($hostingOrder, $products), + ]); + } + + public function sync( + Request $request, + ResellerClubHostingService $hosting, + ResellerClubCustomerService $customers + ): RedirectResponse { + $user = $request->user(); + + if (! $hosting->isConfigured()) { + return redirect()->route('hosting.index')->with('error', 'Hosting service is not configured.'); + } + + $resolved = $customers->resolveCustomerForUser($user); + if (! ($resolved['success'] ?? false)) { + return redirect()->route('hosting.index') + ->with('error', $resolved['message'] ?? 'Could not prepare your ResellerClub customer.'); + } + + $synced = $hosting->syncCustomerOrders((string) $resolved['customer_id'], $user->id); + + return redirect()->route('hosting.index') + ->with('success', "Synced {$synced} hosting order(s) from ResellerClub."); + } + + public function order( + Request $request, + ResellerClubHostingService $hosting, + ResellerClubCustomerService $customers + ): JsonResponse { + $request->validate([ + 'domain_name' => 'required|string|max:253', + 'plan_id' => 'required|string', + 'months' => 'sometimes|integer|min:1|max:120', + ]); + + if (! $hosting->isConfigured()) { + return response()->json(['message' => 'Hosting service is not available at this time.'], 503); + } + + $domainName = strtolower(trim($request->input('domain_name'))); + $planId = $request->input('plan_id'); + $months = $request->integer('months', 1); + + $resolved = $customers->resolveCustomerForUser($request->user()); + if (! ($resolved['success'] ?? false)) { + return response()->json([ + 'message' => $resolved['message'] ?? 'Could not prepare your ResellerClub customer.', + ], 422); + } + + $result = $hosting->orderHosting($domainName, $planId, (string) $resolved['customer_id'], $months); + + if (! $result['success']) { + return response()->json(['message' => $result['message'] ?? 'Hosting order failed.'], 422); + } + + // Fetch details and save locally + $order = null; + if (! empty($result['order_id'])) { + $details = $hosting->getOrderDetails($result['order_id']); + if ($details['success'] ?? false) { + $order = $hosting->syncOrderToLocal( + $details['order']['raw'] ?? $details['order'], + $request->user()->id + ); + } else { + $order = HostingOrder::create([ + 'user_id' => $request->user()->id, + 'domain_name' => $domainName, + 'rc_order_id' => $result['order_id'], + 'plan_name' => $planId, + 'status' => HostingOrder::STATUS_PENDING, + ]); + } + } + + return response()->json([ + 'success' => true, + 'message' => 'Hosting order placed successfully.', + 'order' => $order?->only('id', 'domain_name', 'status', 'rc_order_id'), + ]); + } + + public function resetPanelPassword(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse + { + if ((int) $hostingOrder->user_id !== (int) $request->user()->id) { + abort(403); + } + + $validated = $request->validate([ + 'password' => ['required', 'string', 'min:9', 'max:16', 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[~*!@$#%_+.\?:,{}]).+$/'], + ]); + + if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) { + return response()->json(['message' => 'Hosting service is not available.'], 503); + } + + $result = $hosting->changePanelPassword($hostingOrder->rc_order_id, $validated['password']); + + if (! $result['success']) { + return response()->json(['message' => $result['message'] ?? 'Could not update the cPanel password.'], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'cPanel password updated successfully.', + ]); + } + + public function renew(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse + { + if ((int) $hostingOrder->user_id !== (int) $request->user()->id) { + abort(403); + } + + $request->validate([ + 'months' => 'sometimes|integer|min:1|max:120', + ]); + + if (! ResellerClubLegacy::renewalsEnabled()) { + return response()->json(['message' => ResellerClubLegacy::renewalsDisabledMessage()], 503); + } + + if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) { + return response()->json(['message' => 'Hosting service is not available.'], 503); + } + + $result = $hosting->renewOrder($hostingOrder->rc_order_id, $request->integer('months', 1)); + + if (! $result['success']) { + return response()->json(['message' => $result['message'] ?? 'Renewal failed.'], 422); + } + + // Re-sync details + $details = $hosting->getOrderDetails($hostingOrder->rc_order_id); + if ($details['success'] ?? false) { + $hosting->syncOrderToLocal($details['order']['raw'] ?? $details['order'], $request->user()->id, $hostingOrder->domain_id); + } + + return response()->json(['success' => true, 'message' => 'Hosting renewed successfully.']); + } + + public function destroy(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): RedirectResponse + { + if ((int) $hostingOrder->user_id !== (int) $request->user()->id) { + abort(403); + } + + if ($hostingOrder->status === HostingOrder::STATUS_CANCELLED) { + $redirectRoute = $this->productRouteForOrder($hostingOrder); + $hostingOrder->delete(); + + return redirect()->route($redirectRoute) + ->with('success', 'Cancelled hosting order deleted.'); + } + + if ($hostingOrder->rc_order_id && $hosting->isConfigured()) { + $result = $hosting->deleteOrder($hostingOrder->rc_order_id); + if (! $result['success']) { + return redirect()->route('hosting.show', $hostingOrder) + ->with('error', $result['message'] ?? 'Could not cancel hosting at this time.'); + } + } + + $hostingOrder->update(['status' => HostingOrder::STATUS_CANCELLED]); + + return redirect()->route($this->productRouteForOrder($hostingOrder)) + ->with('success', 'Hosting order cancelled.'); + } + + private function productRouteForOrder(HostingOrder $hostingOrder): string + { + return match ((string) $hostingOrder->hosting_type) { + HostingOrder::TYPE_MULTI_DOMAIN => 'hosting.multi-domain', + HostingOrder::TYPE_RESELLER => 'hosting.multi-domain', + default => 'hosting.single-domain', + }; + } + + /** + * @return array + */ + private function renewalOptionsForOrder(HostingOrder $hostingOrder, ResellerClubProductService $products): array + { + $category = match ((string) $hostingOrder->hosting_type) { + HostingOrder::TYPE_MULTI_DOMAIN => 'multi-domain-hosting', + HostingOrder::TYPE_RESELLER => 'reseller-hosting', + default => 'single-domain-hosting', + }; + + $packages = collect((array) ($products->formDefinition($category)['packages'] ?? [])); + $planId = (string) ($hostingOrder->plan_id ?? ''); + $planName = strtolower(trim((string) $hostingOrder->plan_name)); + + $package = $packages->first(function (array $item) use ($planId, $planName): bool { + $value = (string) ($item['value'] ?? ''); + $label = strtolower(trim((string) ($item['label'] ?? ''))); + + return ($planId !== '' && $value === $planId) + || ($planName !== '' && $label !== '' && $label === $planName); + }); + + if (! is_array($package)) { + return collect(['1', '3', '6', '12', '24', '36']) + ->map(fn (string $months) => [ + 'months' => $months, + 'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'), + 'total' => null, + 'monthly' => null, + ]) + ->all(); + } + + $priceField = ! empty((array) ($package['renew_prices'] ?? [])) ? 'renew_prices' : 'prices'; + $totalField = ! empty((array) ($package['renew_totals'] ?? [])) ? 'renew_totals' : 'totals'; + + return collect(array_keys((array) ($package[$priceField] ?? []))) + ->sortBy(fn (string $months) => (int) $months) + ->values() + ->map(fn (string $months) => [ + 'months' => $months, + 'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'), + 'total' => (string) (($package[$totalField][$months] ?? null) ?: ''), + 'monthly' => (string) (($package[$priceField][$months] ?? null) ?: ''), + ]) + ->all(); + } +} diff --git a/app/Http/Controllers/Hosting/HostingPanelController.php b/app/Http/Controllers/Hosting/HostingPanelController.php new file mode 100644 index 0000000..15efd54 --- /dev/null +++ b/app/Http/Controllers/Hosting/HostingPanelController.php @@ -0,0 +1,2914 @@ +authorizeForRequestUser($request, 'viewPanel', $account); + $account->load(['node', 'product', 'sites', 'databases']); + + $stats = $this->getAccountStats($account); + + return view('hosting.panel.index', [ + 'account' => $account, + 'stats' => $stats, + ]); + } + + public function terminal(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'useTerminal', $account); + $account->load(['node']); + + $path = $this->resolveTerminalPath((string) $request->get('path', '/public_html')); + + return view('hosting.panel.terminal', [ + 'account' => $account, + 'currentPath' => $path, + 'breadcrumbs' => $this->getBreadcrumbs($path), + 'allowedCommands' => [ + 'help', + 'pwd', + 'cd logs', + 'ls -la', + 'php artisan about', + 'git status', + 'composer install', + 'npm run build', + 'clear', + ], + ]); + } + + public function runTerminalCommand(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'useTerminal', $account); + $account->load(['node']); + + if ($account->status === HostingAccount::STATUS_SUSPENDED) { + return response()->json([ + 'success' => false, + 'error' => 'This hosting account is suspended.', + ], 423); + } + + $validated = $request->validate([ + 'command' => 'required|string|max:500', + 'path' => 'nullable|string|max:255', + ]); + + $command = trim((string) $validated['command']); + $path = $this->resolveTerminalPath((string) ($validated['path'] ?? '/public_html')); + + try { + $builtinResult = $this->handleTerminalBuiltin($account, $command, $path); + if ($builtinResult !== null) { + return response()->json($builtinResult); + } + + $this->assertAllowedTerminalCommand($command); + } catch (\RuntimeException $e) { + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + 'cwd' => $path, + ], 422); + } + + $workingDirectory = $this->terminalWorkingDirectory($account, $path); + + try { + $result = $this->runtimeFor($account)->executeTerminalCommand( + $account, + $command, + $workingDirectory + ); + } catch (\RuntimeException $e) { + return response()->json([ + 'success' => false, + 'error' => $e->getMessage(), + 'cwd' => $path, + ], 422); + } + + $output = (string) ($result['output'] ?? ''); + $truncated = false; + if (strlen($output) > 100000) { + $output = substr($output, 0, 100000); + $truncated = true; + } + + return response()->json([ + 'success' => ($result['exit_code'] ?? 1) === 0, + 'exit_code' => (int) ($result['exit_code'] ?? 1), + 'output' => $output, + 'truncated' => $truncated, + 'cwd' => $path, + ]); + } + + private function assertAllowedTerminalCommand(string $command): void + { + if ($command === '') { + throw new \RuntimeException('Command is required.'); + } + + if (preg_match('/[\r\n]/', $command) === 1) { + throw new \RuntimeException('Terminal commands must be a single line.'); + } + + if (preg_match('/(?:\|\||&&|[|;<>`$])/', $command) === 1) { + throw new \RuntimeException('Terminal commands cannot use shell chaining, pipes, or redirection.'); + } + + $token = strtok($command, " \t"); + if ($token === false || $token === '') { + throw new \RuntimeException('Command is required.'); + } + + if (str_contains($token, '..') || str_starts_with($token, '/')) { + throw new \RuntimeException('Terminal command executable is not allowed.'); + } + + $executable = basename(ltrim($token, './')); + if (! in_array($executable, self::TERMINAL_ALLOWED_EXECUTABLES, true)) { + throw new \RuntimeException("`{$executable}` is not available in the panel terminal."); + } + } + + private function handleTerminalBuiltin(HostingAccount $account, string $command, string &$path): ?array + { + if ($command === '') { + throw new \RuntimeException('Command is required.'); + } + + if ($command === 'clear') { + return [ + 'success' => true, + 'exit_code' => 0, + 'output' => '', + 'cwd' => $path, + 'clear' => true, + 'truncated' => false, + ]; + } + + if ($command === 'help') { + return [ + 'success' => true, + 'exit_code' => 0, + 'output' => implode("\n", [ + 'Built-ins: help, clear, pwd, cd', + 'Examples: ls -la, git status, php artisan about, npm run build', + 'Scope: commands run inside your hosting account home directory.', + 'Restrictions: no pipes, redirects, shell chaining, or multi-line commands.', + ]), + 'cwd' => $path, + 'truncated' => false, + ]; + } + + if ($command === 'pwd') { + return [ + 'success' => true, + 'exit_code' => 0, + 'output' => $this->terminalWorkingDirectory($account, $path), + 'cwd' => $path, + 'truncated' => false, + ]; + } + + if (preg_match('/^cd(?:\s+(.*))?$/', $command, $matches) === 1) { + $targetPath = trim((string) ($matches[1] ?? '')); + $nextPath = $targetPath === '' + ? '/' + : $this->resolveTerminalPath($targetPath, $path); + $this->assertTerminalDirectoryExists($account, $nextPath); + $path = $nextPath; + + return [ + 'success' => true, + 'exit_code' => 0, + 'output' => '', + 'cwd' => $path, + 'truncated' => false, + ]; + } + + return null; + } + + // ==================== FILE MANAGER ==================== + + public function fileManager(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + $path = $request->get('path', '/public_html'); + $path = $this->sanitizePath($path); + + $files = $this->listFiles($account, $path); + + return view('hosting.panel.files', [ + 'account' => $account, + 'currentPath' => $path, + 'files' => $files, + 'breadcrumbs' => $this->getBreadcrumbs($path), + ]); + } + + public function fileManagerApi(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + $path = $request->get('path', '/public_html'); + $path = $this->sanitizePath($path); + + $files = $this->listFiles($account, $path); + + return response()->json([ + 'success' => true, + 'path' => $path, + 'files' => $files, + 'breadcrumbs' => $this->getBreadcrumbs($path), + ]); + } + + public function readFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + $path = $request->get('path'); + if (!$path) { + return response()->json(['success' => false, 'error' => 'Path required'], 400); + } + + $path = $this->sanitizePath($path); + $fullPath = "/home/{$account->username}{$path}"; + + $result = $this->runtimeFor($account)->executeCommand($account, "stat -c%s " . escapeshellarg($fullPath) . " 2>/dev/null"); + $size = (int) trim($result['output']); + + if ($size > 1048576) { + return response()->json(['success' => false, 'error' => 'File too large to edit (max 1MB)'], 400); + } + + $result = $this->runtimeFor($account)->executeCommand($account, "cat " . escapeshellarg($fullPath)); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to read file'], 500); + } + + return response()->json([ + 'success' => true, + 'content' => $result['output'], + 'path' => $path, + ]); + } + + public function saveFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'path' => 'required|string', + 'content' => 'required|string', + ]); + + $path = $this->sanitizePath($validated['path']); + $fullPath = "/home/{$account->username}{$path}"; + + $tempFile = "/tmp/edit_" . Str::random(16); + $content = base64_encode($validated['content']); + + $result = $this->runtimeFor($account)->executeCommand( + $account, + "echo '{$content}' | base64 -d > " . escapeshellarg($tempFile) . " && mv " . escapeshellarg($tempFile) . " " . escapeshellarg($fullPath) + ); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to save file'], 500); + } + + return response()->json(['success' => true, 'message' => 'File saved successfully']); + } + + public function createFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'path' => 'required|string', + 'name' => 'required|string|max:255', + 'type' => 'required|in:file,folder', + ]); + + $path = $this->sanitizePath($validated['path']); + $name = basename($validated['name']); + $fullPath = "/home/{$account->username}{$path}/{$name}"; + + if ($validated['type'] === 'folder') { + $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($fullPath)); + } else { + $result = $this->runtimeFor($account)->executeCommand($account, "touch " . escapeshellarg($fullPath)); + } + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to create ' . $validated['type']], 500); + } + + return response()->json(['success' => true, 'message' => ucfirst($validated['type']) . ' created successfully']); + } + + public function deleteFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'deleteFiles', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'path' => 'required|string', + ]); + + $path = $this->sanitizePath($validated['path']); + + if ($path === '/public_html' || $path === '/' || $path === '') { + return response()->json(['success' => false, 'error' => 'Cannot delete this directory'], 400); + } + + $fullPath = "/home/{$account->username}{$path}"; + + $result = $this->runtimeFor($account)->executeCommand($account, "rm -rf " . escapeshellarg($fullPath)); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to delete'], 500); + } + + return response()->json(['success' => true, 'message' => 'Deleted successfully']); + } + + public function renameFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'path' => 'required|string', + 'newName' => 'required|string|max:255', + ]); + + $path = $this->sanitizePath($validated['path']); + $newName = basename($validated['newName']); + $dir = dirname($path); + $newPath = "/home/{$account->username}{$dir}/{$newName}"; + $oldPath = "/home/{$account->username}{$path}"; + + $result = $this->runtimeFor($account)->executeCommand($account, "mv " . escapeshellarg($oldPath) . " " . escapeshellarg($newPath)); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to rename'], 500); + } + + return response()->json(['success' => true, 'message' => 'Renamed successfully']); + } + + public function uploadFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'path' => 'required|string', + 'file' => 'required|file|max:51200', // 50MB max + ]); + + $path = $this->sanitizePath($validated['path']); + $file = $request->file('file'); + $filename = $file->getClientOriginalName(); + $fullPath = "/home/{$account->username}{$path}/{$filename}"; + + // Save the uploaded file to a local temp path first + $tmpPath = "/tmp/upload_" . Str::random(16) . "_" . $filename; + $file->move('/tmp', basename($tmpPath)); + + $runtime = $this->runtimeFor($account); + + // Copy from temp to user directory and set ownership (single command) + $result = $runtime->executeCommandAsRoot( + $account, + "cp " . escapeshellarg($tmpPath) . " " . escapeshellarg($fullPath) + . " && chown {$account->username}:{$account->username} " . escapeshellarg($fullPath) + . " && chmod 644 " . escapeshellarg($fullPath) + . " && rm -f " . escapeshellarg($tmpPath) + ); + + // Clean up temp file if still present + @unlink($tmpPath); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to upload file'], 500); + } + + return response()->json(['success' => true, 'message' => 'File uploaded successfully']); + } + + /** + * Initialize a chunked upload session + */ + public function initChunkedUpload(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'filename' => 'required|string|max:255', + 'filesize' => 'required|integer|min:1', + 'path' => 'required|string', + ]); + + $uploadId = Str::uuid()->toString(); + $tempDir = "/tmp/chunked_uploads/{$uploadId}"; + + if (!is_dir($tempDir)) { + mkdir($tempDir, 0755, true); + } + + // Store upload metadata + file_put_contents("{$tempDir}/metadata.json", json_encode([ + 'filename' => $validated['filename'], + 'filesize' => $validated['filesize'], + 'path' => $this->sanitizePath($validated['path']), + 'account_id' => $account->id, + 'chunks_received' => [], + 'created_at' => now()->toIso8601String(), + ])); + + return response()->json([ + 'success' => true, + 'upload_id' => $uploadId, + ]); + } + + /** + * Upload a single chunk + */ + public function uploadChunk(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'upload_id' => 'required|string|uuid', + 'chunk_index' => 'required|integer|min:0', + 'chunk' => 'required|file|max:10240', // 10MB per chunk + ]); + + $uploadId = $validated['upload_id']; + $chunkIndex = $validated['chunk_index']; + $tempDir = "/tmp/chunked_uploads/{$uploadId}"; + $metadataPath = "{$tempDir}/metadata.json"; + + if (!file_exists($metadataPath)) { + return response()->json(['success' => false, 'error' => 'Upload session not found or expired'], 404); + } + + $metadata = json_decode(file_get_contents($metadataPath), true); + + // Verify this upload belongs to this account + if ($metadata['account_id'] !== $account->id) { + return response()->json(['success' => false, 'error' => 'Unauthorized'], 403); + } + + // Save the chunk and verify size + $chunk = $request->file('chunk'); + $expectedSize = (int) $request->input('chunk_size', 0); + $actualSize = $chunk->getSize(); + + if ($expectedSize > 0 && $actualSize !== $expectedSize) { + return response()->json([ + 'success' => false, + 'error' => "Chunk size mismatch: expected {$expectedSize}, got {$actualSize}" + ], 400); + } + + $chunkPath = "{$tempDir}/chunk_{$chunkIndex}"; + $chunk->move($tempDir, "chunk_{$chunkIndex}"); + + // Verify chunk was saved correctly + if (!file_exists($chunkPath) || filesize($chunkPath) !== $actualSize) { + return response()->json(['success' => false, 'error' => 'Chunk save failed'], 500); + } + + // Update metadata + $metadata['chunks_received'][] = $chunkIndex; + $metadata['chunks_received'] = array_unique($metadata['chunks_received']); + sort($metadata['chunks_received']); + file_put_contents($metadataPath, json_encode($metadata)); + + return response()->json([ + 'success' => true, + 'chunk_index' => $chunkIndex, + 'chunks_received' => count($metadata['chunks_received']), + ]); + } + + /** + * Finalize chunked upload - combine chunks and move to destination + */ + public function finalizeChunkedUpload(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'upload_id' => 'required|string|uuid', + 'total_chunks' => 'required|integer|min:1', + ]); + + $uploadId = $validated['upload_id']; + $totalChunks = $validated['total_chunks']; + $tempDir = "/tmp/chunked_uploads/{$uploadId}"; + $metadataPath = "{$tempDir}/metadata.json"; + + if (!file_exists($metadataPath)) { + return response()->json(['success' => false, 'error' => 'Upload session not found or expired'], 404); + } + + $metadata = json_decode(file_get_contents($metadataPath), true); + + // Verify this upload belongs to this account + if ($metadata['account_id'] !== $account->id) { + return response()->json(['success' => false, 'error' => 'Unauthorized'], 403); + } + + // Verify all chunks are present + if (count($metadata['chunks_received']) !== $totalChunks) { + return response()->json([ + 'success' => false, + 'error' => 'Missing chunks. Expected ' . $totalChunks . ', received ' . count($metadata['chunks_received']), + ], 400); + } + + // Combine chunks into final file using streaming to handle large files + $finalTempPath = "{$tempDir}/final_" . Str::random(8); + $finalFile = fopen($finalTempPath, 'wb'); + + for ($i = 0; $i < $totalChunks; $i++) { + $chunkPath = "{$tempDir}/chunk_{$i}"; + if (!file_exists($chunkPath)) { + fclose($finalFile); + return response()->json(['success' => false, 'error' => "Chunk {$i} is missing"], 400); + } + // Stream chunk to avoid memory issues with large files + $chunkHandle = fopen($chunkPath, 'rb'); + while (!feof($chunkHandle)) { + fwrite($finalFile, fread($chunkHandle, 8192)); + } + fclose($chunkHandle); + } + fclose($finalFile); + + // Verify combined file size matches expected + $combinedSize = filesize($finalTempPath); + $expectedSize = (int) ($metadata['filesize'] ?? 0); + if ($expectedSize > 0 && abs($combinedSize - $expectedSize) > 1024) { + @unlink($finalTempPath); + return response()->json([ + 'success' => false, + 'error' => "File size mismatch. Expected {$expectedSize} bytes, got {$combinedSize} bytes" + ], 400); + } + + // Move to user's directory + $filename = basename($metadata['filename']); + $fullPath = "/home/{$account->username}{$metadata['path']}/{$filename}"; + $runtime = $this->runtimeFor($account); + + $result = $runtime->executeCommandAsRoot( + $account, + "cp " . escapeshellarg($finalTempPath) . " " . escapeshellarg($fullPath) + . " && chown {$account->username}:{$account->username} " . escapeshellarg($fullPath) + . " && chmod 644 " . escapeshellarg($fullPath) + ); + + // Clean up temp directory + $this->cleanupChunkedUpload($tempDir); + + if ($result['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Failed to save file to destination'], 500); + } + + return response()->json([ + 'success' => true, + 'message' => 'File uploaded successfully', + 'filename' => $filename, + ]); + } + + /** + * Clean up a chunked upload directory + */ + private function cleanupChunkedUpload(string $tempDir): void + { + if (!is_dir($tempDir)) { + return; + } + + $files = glob("{$tempDir}/*"); + foreach ($files as $file) { + @unlink($file); + } + @rmdir($tempDir); + } + + public function extractFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'path' => 'required|string', + ]); + + $path = $this->sanitizePath($validated['path']); + $fullPath = "/home/{$account->username}{$path}"; + $dir = dirname($fullPath); + $runtime = $this->runtimeFor($account); + + // Determine extraction command based on extension + $lower = strtolower($fullPath); + if (str_ends_with($lower, '.zip')) { + // Test zip integrity + $test = $runtime->executeCommand( + $account, + "unzip -tqq " . escapeshellarg($fullPath) . " 2>&1", + self::ARCHIVE_COMMAND_TIMEOUT, + ); + if ($test['exit_code'] !== 0) { + return response()->json(['success' => false, 'error' => 'Zip file corrupted or incomplete'], 422); + } + $cmd = "cd " . escapeshellarg($dir) . " && unzip -oq " . escapeshellarg($fullPath) . " 2>&1"; + } elseif (str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz')) { + $cmd = "cd " . escapeshellarg($dir) . " && tar -xzf " . escapeshellarg($fullPath) . " 2>&1"; + } elseif (str_ends_with($lower, '.tar.bz2')) { + $cmd = "cd " . escapeshellarg($dir) . " && tar -xjf " . escapeshellarg($fullPath) . " 2>&1"; + } elseif (str_ends_with($lower, '.tar')) { + $cmd = "cd " . escapeshellarg($dir) . " && tar -xf " . escapeshellarg($fullPath) . " 2>&1"; + } else { + return response()->json(['success' => false, 'error' => 'Unsupported archive format. Supported: .zip, .tar.gz, .tgz, .tar.bz2, .tar'], 422); + } + + // Get file count before extraction + $totalFiles = 0; + if (str_ends_with($lower, '.zip')) { + $countResult = $runtime->executeCommand( + $account, + "unzip -Z1 " . escapeshellarg($fullPath) . " 2>/dev/null | wc -l", + self::ARCHIVE_COMMAND_TIMEOUT, + ); + $totalFiles = max(0, (int) trim($countResult['output'] ?? '0')); + } else { + $listFlag = str_ends_with($lower, '.tar.gz') || str_ends_with($lower, '.tgz') ? '-tzf' : (str_ends_with($lower, '.tar.bz2') ? '-tjf' : '-tf'); + $countResult = $runtime->executeCommand( + $account, + "tar {$listFlag} " . escapeshellarg($fullPath) . " 2>/dev/null | wc -l", + self::ARCHIVE_COMMAND_TIMEOUT, + ); + $totalFiles = max(0, (int) trim($countResult['output'] ?? '0')); + } + + $result = $runtime->executeCommand($account, $cmd, self::ARCHIVE_COMMAND_TIMEOUT); + + if ($result['exit_code'] !== 0) { + $error = trim($result['output'] ?? ''); + return response()->json(['success' => false, 'error' => 'Extraction failed' . ($error ? ': ' . Str::limit($error, 200) : '')], 500); + } + + $extractedFiles = $totalFiles; + + // Fix permissions + $runtime->executeCommand( + $account, + "find " . escapeshellarg($dir) . " -type f -exec chmod 644 {} + 2>/dev/null; find " . escapeshellarg($dir) . " -type d -exec chmod 755 {} + 2>/dev/null", + self::ARCHIVE_COMMAND_TIMEOUT, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Archive extracted successfully', + 'total_files' => $totalFiles, + 'extracted_files' => $extractedFiles, + ]); + } + + public function bulkDelete(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'deleteFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + ]); + + $runtime = $this->runtimeFor($account); + $errors = []; + + foreach ($validated['paths'] as $path) { + $sanitized = $this->sanitizePath($path); + $fullPath = "/home/{$account->username}{$sanitized}"; + $result = $runtime->executeCommand($account, "rm -rf " . escapeshellarg($fullPath)); + if ($result['exit_code'] !== 0) { + $errors[] = basename($sanitized); + } + } + + if ($errors) { + return response()->json(['success' => false, 'error' => 'Failed to delete: ' . implode(', ', $errors)], 500); + } + + return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) deleted']); + } + + public function bulkMove(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + 'destination' => 'required|string', + ]); + + $runtime = $this->runtimeFor($account); + $dest = $this->sanitizePath($validated['destination']); + $destFull = "/home/{$account->username}{$dest}"; + $errors = []; + + foreach ($validated['paths'] as $path) { + $sanitized = $this->sanitizePath($path); + $fullPath = "/home/{$account->username}{$sanitized}"; + $result = $runtime->executeCommand($account, "mv " . escapeshellarg($fullPath) . " " . escapeshellarg($destFull . "/" . basename($sanitized))); + if ($result['exit_code'] !== 0) { + $errors[] = basename($sanitized); + } + } + + if ($errors) { + return response()->json(['success' => false, 'error' => 'Failed to move: ' . implode(', ', $errors)], 500); + } + + return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) moved']); + } + + public function bulkCopy(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + 'destination' => 'required|string', + ]); + + $runtime = $this->runtimeFor($account); + $dest = $this->sanitizePath($validated['destination']); + $destFull = "/home/{$account->username}{$dest}"; + $errors = []; + + foreach ($validated['paths'] as $path) { + $sanitized = $this->sanitizePath($path); + $fullPath = "/home/{$account->username}{$sanitized}"; + $result = $runtime->executeCommand($account, "cp -r " . escapeshellarg($fullPath) . " " . escapeshellarg($destFull . "/" . basename($sanitized))); + if ($result['exit_code'] !== 0) { + $errors[] = basename($sanitized); + } + } + + if ($errors) { + return response()->json(['success' => false, 'error' => 'Failed to copy: ' . implode(', ', $errors)], 500); + } + + return response()->json(['success' => true, 'message' => count($validated['paths']) . ' item(s) copied']); + } + + public function bulkCompress(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + 'archive_name' => 'required|string|max:255', + 'base_path' => 'required|string', + ]); + + $runtime = $this->runtimeFor($account); + $basePath = $this->sanitizePath($validated['base_path']); + $baseFullPath = "/home/{$account->username}{$basePath}"; + $archiveName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $validated['archive_name']); + if (!str_ends_with(strtolower($archiveName), '.zip')) { + $archiveName .= '.zip'; + } + $archivePath = $baseFullPath . '/' . $archiveName; + + $items = []; + foreach ($validated['paths'] as $path) { + $sanitized = $this->sanitizePath($path); + $items[] = escapeshellarg(basename($sanitized)); + } + + $cmd = "cd " . escapeshellarg($baseFullPath) . " && zip -r " . escapeshellarg($archiveName) . " " . implode(' ', $items) . " 2>&1"; + $result = $runtime->executeCommand($account, $cmd); + + if ($result['exit_code'] !== 0) { + $error = trim($result['output'] ?? ''); + return response()->json(['success' => false, 'error' => 'Compression failed' . ($error ? ': ' . Str::limit($error, 200) : '')], 500); + } + + return response()->json(['success' => true, 'message' => 'Archive created: ' . $archiveName]); + } + + public function chmodFile(Request $request, HostingAccount $account): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageFiles', $account); + $account->load(['node']); + + if ($response = $this->guardWritableAccount($account)) { + return $response; + } + + $validated = $request->validate([ + 'paths' => 'required|array|min:1', + 'paths.*' => 'required|string', + 'permissions' => ['required', 'string', 'regex:/^[0-7]{3,4}$/'], + ]); + + $runtime = $this->runtimeFor($account); + $errors = []; + + foreach ($validated['paths'] as $path) { + $sanitized = $this->sanitizePath($path); + $fullPath = "/home/{$account->username}{$sanitized}"; + $result = $runtime->executeCommand($account, "chmod " . escapeshellarg($validated['permissions']) . " " . escapeshellarg($fullPath)); + if ($result['exit_code'] !== 0) { + $errors[] = basename($sanitized); + } + } + + if ($errors) { + return response()->json(['success' => false, 'error' => 'Failed to change permissions: ' . implode(', ', $errors)], 500); + } + + return response()->json(['success' => true, 'message' => 'Permissions updated']); + } + + // ==================== DATABASE MANAGER ==================== + + public function databases(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'manageDatabases', $account); + $account->load(['node', 'databases']); + + return view('hosting.panel.databases', [ + 'account' => $account, + 'phpMyAdminUrl' => $this->phpMyAdminUrlForAccount($account), + ]); + } + + public function createDatabase(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageDatabases', $account); + $account->load(['node', 'product', 'databases']); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases->count() >= $maxDatabases) { + return back()->with('error', "You have reached the maximum number of databases ({$maxDatabases})."); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:32|regex:/^[a-zA-Z0-9_]+$/', + ]); + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbName = $prefix . '_' . $validated['name']; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'host' => 'localhost', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + + return back()->with('success', "Database created successfully. Username: {$dbUser}, Password: {$dbPassword}"); + } catch (\Exception $e) { + $database->delete(); + Log::error("Failed to create database: " . $e->getMessage()); + return back()->with('error', 'Failed to create database. Please try again.'); + } + } + + public function deleteDatabase(Request $request, HostingAccount $account, HostedDatabase $database): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageDatabases', $account); + $this->ensureDatabaseBelongsToAccount($database, $account); + + $account->load(['node']); + + try { + $this->runtimeFor($account)->deleteDatabase($database); + $database->delete(); + + return back()->with('success', 'Database deleted successfully.'); + } catch (\Exception $e) { + Log::error("Failed to delete database: " . $e->getMessage()); + return back()->with('error', 'Failed to delete database. Please try again.'); + } + } + + public function resetDatabasePassword(Request $request, HostingAccount $account, HostedDatabase $database): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageDatabases', $account); + $this->ensureDatabaseBelongsToAccount($database, $account); + + $account->load(['node']); + $newPassword = Str::password(16, true, true, false, false); + + try { + $this->runtimeFor($account)->changeDatabasePassword($database, $newPassword); + + // Store the new encrypted password for SSO + $database->update(['password_encrypted' => encrypt($newPassword)]); + + return response()->json([ + 'success' => true, + 'password' => $newPassword, + 'message' => 'Password reset successfully.', + ]); + } catch (\Exception $e) { + Log::error("Failed to reset database password: " . $e->getMessage()); + return response()->json(['success' => false, 'error' => 'Failed to reset password.'], 500); + } + } + + // ==================== DOMAIN MANAGER ==================== + + public function domains(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'viewDomains', $account); + $account->load(['node', 'product', 'sites']); + + $ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account); + + return view('hosting.panel.domains', [ + 'account' => $account, + 'ownedDomains' => $ownedDomains, + ]); + } + + public function addDomain( + Request $request, + HostingAccount $account, + DomainDnsBlueprintService $blueprints, + DomainVerificationService $verification + ): RedirectResponse|\Illuminate\Http\JsonResponse + { + $this->authorizeForRequestUser($request, 'manageDomains', $account); + $account->load(['node', 'product', 'sites']); + + $maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1; + if ($account->sites->count() >= $maxDomains) { + if ($request->wantsJson()) { + return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422); + } + return back()->with('error', "You have reached the maximum number of domains ({$maxDomains})."); + } + + $isOwnedDomain = $request->boolean('is_owned_domain'); + + $rules = [ + 'domain' => 'required|string|max:255|regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/', + 'document_root' => 'nullable|string|max:255', + ]; + + if (! $isOwnedDomain) { + $rules['onboarding_mode'] = 'nullable|string|in:ns_auto,manual_dns'; + } + + $validated = $request->validate($rules); + + $domainHost = strtolower($validated['domain']); + $onboardingMode = $isOwnedDomain + ? Domain::MODE_NS_AUTO + : ($validated['onboarding_mode'] ?? Domain::MODE_NS_AUTO); + $serverIp = $account->node?->ip_address ?? '161.97.138.149'; + $docRoot = ! empty($validated['document_root']) + ? "/home/{$account->username}/" . ltrim($validated['document_root'], '/') + : "/home/{$account->username}/public_html/{$domainHost}"; + + // Check if domain is already linked (include soft-deleted to handle unique constraint) + $existingSite = HostedSite::withTrashed()->where('domain', $domainHost)->first(); + if ($existingSite) { + if ($existingSite->trashed()) { + if ($existingSite->hosting_account_id !== $account->id) { + if ($request->wantsJson()) { + return response()->json(['message' => 'This domain is already in use on another hosting account.'], 422); + } + return back()->with('error', 'This domain is already in use on another hosting account.'); + } + + // Restore the soft-deleted site and recreate nginx config + $existingSite->restore(); + $existingSite->fill([ + 'document_root' => $docRoot, + 'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2', + 'status' => 'active', + 'type' => $existingSite->type ?: 'addon', + ])->save(); + try { + $existingSite->loadMissing(['account.node']); + $this->runtimeFor($account)->addSite($existingSite); + + // Queue SSL provisioning for restored domain + ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2)); + + if ($request->wantsJson()) { + return response()->json(['success' => true, 'message' => "Domain {$domainHost} has been restored. SSL will be provisioned automatically."]); + } + return back()->with('success', "Domain {$domainHost} has been restored. SSL will be provisioned automatically."); + } catch (\Exception $e) { + $existingSite->delete(); + Log::error("Failed to restore domain nginx config: " . $e->getMessage()); + if ($request->wantsJson()) { + return response()->json(['message' => 'Failed to restore domain. Please try again.'], 500); + } + return back()->with('error', 'Failed to restore domain. Please try again.'); + } + } + + if ($existingSite->hosting_account_id !== $account->id) { + if ($request->wantsJson()) { + return response()->json(['message' => 'This domain is already in use on another hosting account.'], 422); + } + return back()->with('error', 'This domain is already in use on another hosting account.'); + } + + // Site already exists on this account + if ($request->wantsJson()) { + return response()->json(['success' => true, 'message' => "Domain {$domainHost} is already linked to this hosting account."]); + } + return back()->with('success', "Domain {$domainHost} is already linked to this hosting account."); + } + + // Check if domain already exists in the system, reuse or create new + $domain = Domain::where('host', $domainHost)->first(); + + if (!$domain) { + $domain = Domain::create([ + 'user_id' => $account->user_id, + 'hosting_account_id' => $account->id, + 'host' => $domainHost, + 'type' => 'custom', + 'source' => 'hosting', + 'onboarding_mode' => $onboardingMode, + 'verification_token' => bin2hex(random_bytes(16)), + 'status' => 'verified', + 'onboarding_state' => Domain::STATE_ACTIVE, + 'dns_mode' => $onboardingMode === Domain::MODE_MANUAL_DNS ? 'manual' : 'managed', + 'ns_expected' => $blueprints->expectedNameservers(), + 'verified_at' => now(), + 'connected_at' => now(), + 'mail_ready_at' => now(), + 'active_at' => now(), + 'verification_meta' => [ + 'created_via' => $onboardingMode, + 'mail_setup' => $blueprints->mailSetupInstructions(), + 'hosting_account_id' => $account->id, + 'server_ip' => $serverIp, + ], + ]); + } elseif ($domain->user_id !== $account->user_id) { + if ($request->wantsJson()) { + return response()->json(['message' => 'This domain is already registered to another user.'], 422); + } + return back()->with('error', 'This domain is already registered to another user.'); + } else { + $verificationMeta = (array) $domain->verification_meta; + + $domain->update([ + 'hosting_account_id' => $account->id, + 'onboarding_mode' => $domain->isActiveForMail() ? $domain->onboarding_mode : $onboardingMode, + 'dns_mode' => $domain->isActiveForMail() + ? $domain->dns_mode + : ($onboardingMode === Domain::MODE_MANUAL_DNS ? 'manual' : 'managed'), + 'ns_expected' => $blueprints->expectedNameservers(), + 'verification_meta' => array_merge($verificationMeta, [ + 'created_via' => $domain->isActiveForMail() + ? ($verificationMeta['created_via'] ?? $domain->onboarding_mode) + : $onboardingMode, + 'mail_setup' => $blueprints->mailSetupInstructions(), + 'hosting_account_id' => $account->id, + 'server_ip' => $serverIp, + ]), + ]); + } + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'domain' => $domainHost, + 'document_root' => $docRoot, + 'type' => 'addon', + 'php_version' => $account->php_version ?? '8.2', + 'ssl_enabled' => false, + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->addSite($site); + + // Queue DNS zone provisioning (manual pack is local-only). + if ($onboardingMode === Domain::MODE_MANUAL_DNS) { + $verification->prepareManualDnsPack($domain); + } else { + $verification->reprovision($domain); + } + + // Queue SSL provisioning (delayed to allow DNS propagation) + ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2)); + + $message = $onboardingMode === Domain::MODE_MANUAL_DNS + ? "Domain {$domainHost} linked successfully. SSL will be provisioned automatically once DNS is configured." + : "Domain {$domainHost} linked successfully. SSL will be provisioned automatically once nameservers are updated."; + + if ($request->wantsJson()) { + return response()->json(['success' => true, 'message' => $message, 'domain' => $domain->only('id', 'host', 'status')]); + } + return back()->with('success', $message); + } catch (\Exception $e) { + $site->delete(); + // Don't delete the domain record as it may be used elsewhere + Log::error("Failed to add domain: " . $e->getMessage()); + if ($request->wantsJson()) { + return response()->json(['message' => 'Failed to add domain. Please try again.'], 500); + } + return back()->with('error', 'Failed to add domain. Please try again.'); + } + } + + public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse + { + abort_unless((int) $account->user_id === (int) $request->user()->id, 403); + $this->ensureSiteBelongsToAccount($site, $account); + + $account->load(['node']); + + try { + $this->runtimeFor($account)->removeSite($site); + $site->delete(); + + return back()->with('success', 'Domain removed successfully.'); + } catch (\Exception $e) { + Log::error("Failed to remove domain: " . $e->getMessage()); + return back()->with('error', 'Failed to remove domain. Please try again.'); + } + } + + private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection + { + $linkedDomains = HostedSite::whereNotNull('domain') + ->pluck('domain') + ->map(fn ($d) => strtolower(trim((string) $d))) + ->filter() + ->all(); + + $domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host'); + + $registeredDomains = RcServiceOrder::where('user_id', $userId) + ->whereIn('order_type', [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER]) + ->where('status', RcServiceOrder::STATUS_ACTIVE) + ->whereNotNull('domain_name') + ->pluck('domain_name'); + + $customerHostingDomains = CustomerHostingOrder::where('user_id', $userId) + ->whereNotIn('status', [ + CustomerHostingOrder::STATUS_PENDING_PAYMENT, + CustomerHostingOrder::STATUS_CANCELLED, + CustomerHostingOrder::STATUS_FAILED, + ]) + ->whereNotNull('domain_name') + ->pluck('domain_name'); + + $hostingAccountDomains = HostingAccount::where('user_id', $userId) + ->whereNotNull('primary_domain') + ->pluck('primary_domain'); + + return $domainRegistryHosts + ->merge($registeredDomains) + ->merge($customerHostingDomains) + ->merge($hostingAccountDomains) + ->map(fn ($d) => strtolower(trim((string) $d))) + ->filter(fn ($d) => (bool) preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $d)) + ->reject(fn ($d) => in_array($d, $linkedDomains, true)) + ->unique() + ->sort() + ->values(); + } + + // ==================== PHP CONFIGURATION ==================== + + public function phpConfig(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'managePhp', $account); + $account->load(['node', 'product']); + + $availableVersions = ['8.0', '8.1', '8.2', '8.3', '8.4']; + $currentVersion = $account->php_version ?? '8.4'; + + // Read current php.ini settings + $phpSettings = $this->getPhpSettings($account); + + return view('hosting.panel.php', [ + 'account' => $account, + 'availableVersions' => $availableVersions, + 'currentVersion' => $currentVersion, + 'phpSettings' => $phpSettings, + ]); + } + + public function changePhpVersion(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'managePhp', $account); + $account->load(['node', 'sites']); + + $validated = $request->validate([ + 'php_version' => 'required|string|in:8.0,8.1,8.2,8.3,8.4', + ]); + + try { + $newVersion = $validated['php_version']; + $currentVersion = $account->php_version ?? '8.4'; + + if ($currentVersion === $newVersion) { + return back()->with('success', "PHP version is already {$newVersion}."); + } + + if (! $this->runtimeFor($account)->changeAccountPhpVersion($account, $newVersion)) { + return back()->with('error', 'Failed to change PHP version. Please try again.'); + } + + $account->update(['php_version' => $newVersion]); + + foreach ($account->sites as $site) { + $site->update(['php_version' => $newVersion]); + } + + return back()->with('success', "PHP version changed to {$newVersion} successfully."); + } catch (\Exception $e) { + Log::error("Failed to change PHP version: " . $e->getMessage()); + return back()->with('error', 'Failed to change PHP version. Please try again.'); + } + } + + public function savePhpSettings(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'managePhp', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'upload_max_filesize' => 'required|integer|min:1|max:512', + 'post_max_size' => 'required|integer|min:1|max:512', + 'memory_limit' => 'required|integer|min:32|max:1024', + 'max_execution_time' => 'required|integer|min:30|max:600', + 'max_input_vars' => 'required|integer|min:1000|max:10000', + ]); + + try { + $this->saveUserPhpIni($account, $validated); + return back()->with('success', 'PHP settings saved successfully.'); + } catch (\Exception $e) { + Log::error("Failed to save PHP settings: " . $e->getMessage()); + return back()->with('error', 'Failed to save PHP settings. Please try again.'); + } + } + + private function getPhpSettings(HostingAccount $account): array + { + $defaults = [ + 'upload_max_filesize' => 64, + 'post_max_size' => 64, + 'memory_limit' => 256, + 'max_execution_time' => 300, + 'max_input_vars' => 3000, + ]; + + $iniPath = "/home/{$account->username}/.user.ini"; + $result = $this->runtimeFor($account)->executeCommand($account, "cat " . escapeshellarg($iniPath) . " 2>/dev/null"); + + if ($result['exit_code'] !== 0 || empty(trim($result['output']))) { + return $defaults; + } + + $settings = $defaults; + foreach (explode("\n", $result['output']) as $line) { + if (preg_match('/^(\w+)\s*=\s*(\d+)/', $line, $matches)) { + $key = $matches[1]; + $value = (int) $matches[2]; + if (isset($settings[$key])) { + $settings[$key] = $value; + } + } + } + + return $settings; + } + + private function saveUserPhpIni(HostingAccount $account, array $settings): void + { + $content = "; Custom PHP settings for {$account->username}\n"; + $content .= "; Generated by Ladill Hosting Panel\n\n"; + $content .= "upload_max_filesize = {$settings['upload_max_filesize']}M\n"; + $content .= "post_max_size = {$settings['post_max_size']}M\n"; + $content .= "memory_limit = {$settings['memory_limit']}M\n"; + $content .= "max_execution_time = {$settings['max_execution_time']}\n"; + $content .= "max_input_vars = {$settings['max_input_vars']}\n"; + + $iniPath = "/home/{$account->username}/.user.ini"; + $encoded = base64_encode($content); + + $this->runtimeFor($account)->executeCommand( + $account, + "echo '{$encoded}' | base64 -d > " . escapeshellarg($iniPath) + ); + + // Also copy to public_html for PHP-FPM to pick up + $publicIniPath = "/home/{$account->username}/public_html/.user.ini"; + $this->runtimeFor($account)->executeCommand( + $account, + "cp " . escapeshellarg($iniPath) . " " . escapeshellarg($publicIniPath) + ); + } + + // ==================== SSL MANAGEMENT ==================== + + public function ssl(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'manageSsl', $account); + $account->load(['node', 'sites']); + + return view('hosting.panel.ssl', [ + 'account' => $account, + ]); + } + + public function requestSsl(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageSsl', $account); + $this->ensureSiteBelongsToAccount($site, $account); + $account->load(['node']); + + try { + $this->runtimeFor($account)->requestLetsEncryptCertificate($site); + $site->update([ + 'ssl_enabled' => true, + 'ssl_status' => 'issued', + 'ssl_provisioned_at' => now(), + 'ssl_error' => null, + ]); + + return back()->with('success', "SSL certificate issued for {$site->domain} successfully."); + } catch (\Exception $e) { + Log::error("Failed to request SSL: " . $e->getMessage()); + + $message = trim($e->getMessage()); + + return back()->with( + 'error', + $message !== '' + ? 'Failed to issue SSL certificate: ' . $message + : 'Failed to issue SSL certificate. Make sure your domain DNS is pointing to this server.' + ); + } + } + + // ==================== CRON JOBS ==================== + + public function cronJobs(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'manageCron', $account); + $account->load(['node']); + + $cronJobs = $this->getCronJobs($account); + + return view('hosting.panel.cron', [ + 'account' => $account, + 'cronJobs' => $cronJobs, + ]); + } + + public function addCronJob(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageCron', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'minute' => 'required|string|max:10', + 'hour' => 'required|string|max:10', + 'day' => 'required|string|max:10', + 'month' => 'required|string|max:10', + 'weekday' => 'required|string|max:10', + 'command' => 'required|string|max:500', + ]); + + try { + $cronLine = "{$validated['minute']} {$validated['hour']} {$validated['day']} {$validated['month']} {$validated['weekday']} {$validated['command']}"; + $this->addCronEntry($account, $cronLine); + + return back()->with('success', 'Cron job added successfully.'); + } catch (\Exception $e) { + Log::error("Failed to add cron job: " . $e->getMessage()); + return back()->with('error', 'Failed to add cron job. Please try again.'); + } + } + + public function deleteCronJob(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageCron', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'line_number' => 'required|integer|min:1', + ]); + + try { + $this->deleteCronEntry($account, $validated['line_number']); + return back()->with('success', 'Cron job deleted successfully.'); + } catch (\Exception $e) { + Log::error("Failed to delete cron job: " . $e->getMessage()); + return back()->with('error', 'Failed to delete cron job. Please try again.'); + } + } + + private function getCronJobs(HostingAccount $account): array + { + $result = $this->runtimeFor($account)->executeCommand($account, "crontab -l 2>/dev/null"); + + if ($result['exit_code'] !== 0 || empty(trim($result['output']))) { + return []; + } + + $jobs = []; + $lineNumber = 0; + foreach (explode("\n", $result['output']) as $line) { + $lineNumber++; + $line = trim($line); + if (empty($line) || str_starts_with($line, '#')) continue; + + if (preg_match('/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $matches)) { + $jobs[] = [ + 'line_number' => $lineNumber, + 'minute' => $matches[1], + 'hour' => $matches[2], + 'day' => $matches[3], + 'month' => $matches[4], + 'weekday' => $matches[5], + 'command' => $matches[6], + 'schedule' => "{$matches[1]} {$matches[2]} {$matches[3]} {$matches[4]} {$matches[5]}", + ]; + } + } + + return $jobs; + } + + private function addCronEntry(HostingAccount $account, string $cronLine): void + { + $this->runtimeFor($account)->executeCommand( + $account, + "(crontab -l 2>/dev/null; echo " . escapeshellarg($cronLine) . ") | crontab -" + ); + } + + private function deleteCronEntry(HostingAccount $account, int $lineNumber): void + { + $this->runtimeFor($account)->executeCommand( + $account, + "crontab -l 2>/dev/null | sed '{$lineNumber}d' | crontab -" + ); + } + + // ==================== ERROR LOGS ==================== + + public function logs(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'viewLogs', $account); + $account->load(['node', 'sites']); + + $logType = $request->get('type', 'error'); + $lines = (int) $request->get('lines', 100); + $lines = min(max($lines, 50), 500); + + $logContent = $this->getLogContent($account, $logType, $lines); + $logFilePath = $this->getLogFilePath($account, $logType); + $errorLogPath = $this->getLogFilePath($account, 'error'); + $accessLogPath = $this->getLogFilePath($account, 'access'); + + return view('hosting.panel.logs', [ + 'account' => $account, + 'logType' => $logType, + 'lines' => $lines, + 'logContent' => $logContent, + 'logFilePath' => $logFilePath, + 'errorLogPath' => $errorLogPath, + 'accessLogPath' => $accessLogPath, + ]); + } + + public function clearLogs(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'clearLogs', $account); + $account->load(['node']); + + $logType = $request->get('type', 'error'); + + try { + $logFile = $this->getLogFilePath($account, $logType); + $this->runtimeFor($account)->executeCommand($account, "truncate -s 0 " . escapeshellarg($logFile) . " 2>/dev/null"); + + return back()->with('success', 'Log file cleared successfully.'); + } catch (\Exception $e) { + return back()->with('error', 'Failed to clear log file: ' . $e->getMessage()); + } + } + + private function getLogFilePath(HostingAccount $account, string $type): string + { + return $this->getLogFileCandidates($account, $type)[0]; + } + + private function getLogContent(HostingAccount $account, string $type, int $lines = 100): string + { + $candidates = $this->getLogFileCandidates($account, $type); + + foreach ($candidates as $logFile) { + $result = $this->runtimeFor($account)->executeCommand( + $account, + "test -f " . escapeshellarg($logFile) . " && tail -n {$lines} " . escapeshellarg($logFile) . " 2>/dev/null" + ); + + if (($result['exit_code'] ?? 1) === 0 && trim((string) ($result['output'] ?? '')) !== '') { + return (string) $result['output']; + } + } + + return 'Log file not found or is empty.'; + } + + private function getLogFileCandidates(HostingAccount $account, string $type): array + { + $logSuffix = $type === 'access' ? 'access' : 'error'; + $basePath = "/home/{$account->username}/logs"; + $candidates = []; + + $primaryDomain = $this->getPrimaryLogDomain($account); + + if ($primaryDomain) { + $candidates[] = "{$basePath}/{$primaryDomain}-{$logSuffix}.log"; + } + + $candidates[] = "{$basePath}/{$logSuffix}.log"; + + return array_values(array_unique($candidates)); + } + private function getPrimaryLogDomain(HostingAccount $account): ?string + { + return $account->primary_domain ?: $account->sites->first()?->domain; + } + + // ==================== APP INSTALLER ==================== + + public function apps(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'manageApps', $account); + $account->load(['node', 'product', 'sites.domain']); + + $isWordPressPlan = $account->product?->type === \App\Models\HostingProduct::TYPE_WORDPRESS; + + $allApps = [ + [ + 'id' => 'wordpress', + 'name' => 'WordPress', + 'description' => 'Popular CMS and blogging platform', + 'icon' => 'wordpress', + 'category' => 'cms', + ], + [ + 'id' => 'joomla', + 'name' => 'Joomla', + 'description' => 'Flexible CMS for websites and apps', + 'icon' => 'joomla', + 'category' => 'cms', + ], + [ + 'id' => 'drupal', + 'name' => 'Drupal', + 'description' => 'Enterprise-grade CMS platform', + 'icon' => 'drupal', + 'category' => 'cms', + ], + [ + 'id' => 'opencart', + 'name' => 'OpenCart', + 'description' => 'Free e-commerce platform', + 'icon' => 'opencart', + 'category' => 'ecommerce', + ], + [ + 'id' => 'magento', + 'name' => 'Magento', + 'description' => 'Powerful e-commerce solution', + 'icon' => 'magento', + 'category' => 'ecommerce', + ], + [ + 'id' => 'laravel', + 'name' => 'Laravel', + 'description' => 'PHP web application framework', + 'icon' => 'laravel', + 'category' => 'framework', + ], + [ + 'id' => 'nodejs', + 'name' => 'Node.js App', + 'description' => 'JavaScript runtime application', + 'icon' => 'nodejs', + 'category' => 'runtime', + ], + [ + 'id' => 'python', + 'name' => 'Python App', + 'description' => 'Python WSGI application', + 'icon' => 'python', + 'category' => 'runtime', + ], + ]; + + $availableApps = $isWordPressPlan + ? array_values(array_filter($allApps, fn ($app) => $app['id'] === 'wordpress')) + : $allApps; + + return view('hosting.panel.apps', [ + 'account' => $account, + 'availableApps' => $availableApps, + 'isWordPressPlan' => $isWordPressPlan, + ]); + } + + public function installApp(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageApps', $account); + $account->load(['node', 'product', 'databases', 'user']); + + $validated = $request->validate([ + 'app' => 'required|string|in:wordpress,joomla,drupal,opencart,magento,laravel,nodejs,python', + 'site_id' => 'required|exists:hosted_sites,id', + 'directory' => 'nullable|string|max:100|regex:/^[a-zA-Z0-9_-]*$/', + 'php_version' => 'nullable|string|in:8.1,8.2,8.3,8.4', + ]); + + $site = HostedSite::findOrFail($validated['site_id']); + $this->ensureSiteBelongsToAccount($site, $account); + + // Check if site already has an app installed + if ($site->installed_app) { + return back()->with('error', 'This site already has an application installed. Please delete it first.'); + } + + // Check if there's already an installation in progress + $existingInstallation = AppInstallation::where('hosted_site_id', $site->id) + ->where('status', 'installing') + ->first(); + if ($existingInstallation) { + return back()->with('error', 'An installation is already in progress for this site.'); + } + + // Delete any failed installations for this site (user is retrying) + AppInstallation::where('hosted_site_id', $site->id) + ->where('status', 'failed') + ->delete(); + + $defaultDocRoot = $this->defaultSiteDocumentRoot($account, $site); + if ($this->hasAppSpecificDocumentRoot($site->document_root, $defaultDocRoot)) { + $this->runtimeFor($account)->restoreDefaultSiteConfig($account, $site, $defaultDocRoot); + $site->update(['document_root' => $defaultDocRoot]); + $site->refresh(); + } + + $directory = $validated['directory'] ?? ''; + $docRoot = $site->document_root; + $installPath = $directory + ? "{$docRoot}/{$directory}" + : $docRoot; + $displayPath = $directory ? "{$site->domain}/{$directory}" : $site->domain; + $installUrl = ($site->ssl_enabled ? 'https://' : 'http://') . $displayPath; + + // Determine PHP version (default to 8.4 for PHP apps, null for non-PHP apps) + $phpApps = ['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel']; + $phpVersion = in_array($validated['app'], $phpApps) + ? ($validated['php_version'] ?? '8.4') + : null; + + // Create AppInstallation record with installing status + $installation = AppInstallation::create([ + 'hosted_site_id' => $site->id, + 'app_type' => $validated['app'], + 'app_version' => 'latest', + 'install_url' => $installUrl, + 'status' => 'installing', + 'progress' => 0, + 'progress_message' => 'Queued for installation...', + 'total_steps' => $this->getInstallationSteps($validated['app']), + 'config' => $phpVersion ? ['php_version' => $phpVersion] : null, + ]); + + // Dispatch the installation job to Redis queue + \App\Jobs\AppInstallationJob::dispatch( + $installation, + $account, + $site, + $installPath, + $installUrl + ); + + return back()->with('success', ucfirst($validated['app']) . " installation started for {$displayPath}. You can track the progress below."); + } + + public function installationProgress(Request $request, HostingAccount $account, AppInstallation $installation): JsonResponse + { + $this->authorizeForRequestUser($request, 'manageApps', $account); + abort_if((int) $installation->site->hosting_account_id !== (int) $account->id, 403); + + return response()->json([ + 'id' => $installation->id, + 'status' => $installation->status, + 'progress' => $installation->progress, + 'progress_message' => $installation->progress_message, + 'current_step' => $installation->current_step, + 'total_steps' => $installation->total_steps, + 'error_message' => $installation->error_message, + 'app_type' => $installation->app_type, + 'installed_at' => $installation->installed_at?->toIso8601String(), + ]); + } + + private function getInstallationSteps(string $appType): int + { + return match ($appType) { + 'wordpress' => 6, + 'joomla' => 5, + 'drupal' => 5, + 'opencart' => 5, + 'magento' => 6, + 'laravel' => 4, + 'nodejs' => 3, + 'python' => 3, + default => 5, + }; + } + + public function deleteApp(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse + { + $this->authorizeForRequestUser($request, 'manageApps', $account); + $this->ensureSiteBelongsToAccount($site, $account); + + if (!$site->installed_app) { + return back()->with('error', 'No application installed on this site.'); + } + + $appName = ucfirst($site->installed_app); + $appType = $site->installed_app; + $account->load('node'); + + try { + // For Python/Node.js apps, stop the service and restore nginx to PHP-FPM + if (in_array($appType, ['python', 'nodejs'], true)) { + $this->runtimeFor($account)->removeAppService($site); + + // Clean up Node.js specific files + if ($appType === 'nodejs') { + $docRoot = $site->document_root; + // Remove node_modules and package files + $this->runtimeFor($account)->executeCommand( + $account, + "rm -rf " . escapeshellarg($docRoot) . "/node_modules " . + escapeshellarg($docRoot) . "/package.json " . + escapeshellarg($docRoot) . "/package-lock.json " . + escapeshellarg($docRoot) . "/npm-debug.log 2>&1 || true" + ); + } + + // Clean up Python specific files + if ($appType === 'python') { + $docRoot = $site->document_root; + // Remove virtual environment and Python files + $this->runtimeFor($account)->executeCommand( + $account, + "rm -rf " . escapeshellarg($docRoot) . "/venv " . + escapeshellarg($docRoot) . "/__pycache__ " . + escapeshellarg($docRoot) . "/.venv " . + escapeshellarg($docRoot) . "/requirements.txt 2>&1 || true" + ); + // Remove all .pyc files + $this->runtimeFor($account)->executeCommand( + $account, + "find " . escapeshellarg($docRoot) . " -name '*.pyc' -delete 2>&1 || true" + ); + } + } + + // For PHP apps that may have changed document root, restore nginx config first + if (in_array($appType, ['laravel', 'magento'], true)) { + // Restore nginx to default PHP-FPM config (before deleting files) + $defaultDocRoot = $this->defaultSiteDocumentRoot($account, $site); + $this->runtimeFor($account)->restoreDefaultSiteConfig($account, $site, $defaultDocRoot); + $site->update(['document_root' => $defaultDocRoot]); + $site->refresh(); + } + + $docRoot = $site->document_root; + + // Use root to forcefully remove all files (handles permission issues) + // First try as user, then as root if needed + $result = $this->runtimeFor($account)->executeCommand( + $account, + "rm -rf " . escapeshellarg($docRoot) . "/* " . escapeshellarg($docRoot) . "/.[!.]* 2>&1" + ); + + // If user-level deletion had issues, force cleanup as root + if ($result['exit_code'] !== 0 || $this->hasFilesRemaining($account, $docRoot)) { + $this->runtimeFor($account)->executeCommandAsRoot( + $account, + "rm -rf " . escapeshellarg($docRoot) . "/* " . escapeshellarg($docRoot) . "/.[!.]* " . + escapeshellarg($docRoot) . "/..?* 2>&1 || true" + ); + } + + // For apps with subdirectories (Laravel/Magento), clean parent directory too + if (in_array($appType, ['laravel', 'magento'], true)) { + $parentDir = dirname($docRoot); + // Check if parent is not just the account's home root + if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") { + $this->runtimeFor($account)->executeCommandAsRoot( + $account, + "rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true" + ); + } + } + + // Clean up database and database user properly + if ($site->appInstallation && $site->appInstallation->database_name) { + $dbName = $site->appInstallation->database_name; + $database = $account->databases()->where('name', $dbName)->first(); + + if ($database) { + $dbUser = $database->username; // Use actual username from database record + + // Drop database and user separately for clarity + $this->runtimeFor($account)->executeCommandAsRoot( + $account, + "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1" + ); + $database->delete(); + } else { + // Database record not found, try to clean up anyway with the stored name + $this->runtimeFor($account)->executeCommandAsRoot( + $account, + "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true" + ); + } + } + + // Also call AppInstaller.uninstall for additional cleanup if available + try { + $appInstaller = app(\App\Services\Hosting\AppInstaller::class); + if ($site->appInstallation) { + $appInstaller->uninstall($site->appInstallation); + } + } catch (\Exception $e) { + Log::warning("AppInstaller uninstall failed (non-critical): " . $e->getMessage()); + } + + if ($site->appInstallation) { + $site->appInstallation->delete(); + } + + $site->update([ + 'installed_app' => null, + 'installed_app_version' => null, + 'app_config' => null, + ]); + + if ($appType === 'magento') { + $opensearchCheck = $this->runtimeFor($account)->executeCommandAsRoot( + $account, + '/usr/local/bin/check-opensearch-for-magento.sh 2>&1' + ); + + if (($opensearchCheck['exit_code'] ?? 0) !== 0) { + Log::warning('Failed to refresh Magento OpenSearch state after Magento deletion.', [ + 'account_id' => $account->id, + 'site_id' => $site->id, + 'output' => trim((string) ($opensearchCheck['output'] ?? '')), + ]); + } + } + + return back()->with('success', "{$appName} has been uninstalled successfully."); + } catch (\Exception $e) { + Log::error("Failed to delete app: " . $e->getMessage()); + return back()->with('error', 'Failed to uninstall application: ' . $e->getMessage()); + } + } + + /** + * Check if files remain in directory after deletion attempt + */ + private function hasFilesRemaining(HostingAccount $account, string $path): bool + { + $result = $this->runtimeFor($account)->executeCommand( + $account, + "ls -A " . escapeshellarg($path) . " 2>/dev/null | wc -l" + ); + return ((int) trim($result['output'] ?? '0')) > 0; + } + + private function defaultSiteDocumentRoot(HostingAccount $account, HostedSite $site): string + { + return "/home/{$account->username}/public_html/{$site->domain}"; + } + + private function hasAppSpecificDocumentRoot(?string $documentRoot, string $defaultDocRoot): bool + { + if (! $documentRoot) { + return false; + } + + $normalizedRoot = rtrim($documentRoot, '/'); + $normalizedDefault = rtrim($defaultDocRoot, '/'); + + return in_array($normalizedRoot, [ + "{$normalizedDefault}/public", + "{$normalizedDefault}/pub", + "{$normalizedDefault}/web", + ], true); + } + + private function installLaravel(HostingAccount $account, string $path): void + { + $result = $this->runtimeFor($account)->executeCommand( + $account, + "cd /home/{$account->username}/public_html && composer create-project laravel/laravel " . escapeshellarg(basename($path)) . " --prefer-dist 2>&1" + ); + + if ($result['exit_code'] !== 0) { + Log::error("Laravel install failed", ['output' => $result['output']]); + throw new \RuntimeException( + 'Laravel installation failed. ' . $this->formatInstallerCommandFailure('Create Laravel application', $result) + ); + } + } + + private function installWordPress(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array + { + Log::info("Installing WordPress to: {$path}"); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases()->count() >= $maxDatabases) { + throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); + } + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); + $dbName = $prefix . '_' . $dbSuffix; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; + $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; + $adminPassword = Str::password(16, true, true, false, false); + $siteTitle = $site->domain; + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); + + $quotedPath = escapeshellarg($path); + $probePath = escapeshellarg($path . '/.ladill_write_test'); + $quotedInstallUrl = escapeshellarg($installUrl); + $quotedSiteTitle = escapeshellarg($siteTitle); + $quotedAdminUsername = escapeshellarg($adminUsername); + $quotedAdminEmail = escapeshellarg($adminEmail); + $quotedAdminPassword = escapeshellarg($adminPassword); + + $commands = [ + 'Verify installation directory is writable' => "test -w {$quotedPath} && touch {$probePath} && rm -f {$probePath} 2>&1", + 'Download WordPress package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://wordpress.org/latest.tar.gz -o latest.tar.gz 2>&1", + 'Extract WordPress package' => "cd {$quotedPath} && tar -xzf latest.tar.gz --strip-components=1 2>&1", + 'Remove installation archive' => "cd {$quotedPath} && rm -f latest.tar.gz 2>&1", + 'Download WP-CLI' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o wp-cli.phar 2>&1", + 'Create WordPress configuration' => "cd {$quotedPath} && php wp-cli.phar config create --dbname=" . escapeshellarg($dbName) . " --dbuser=" . escapeshellarg($dbUser) . " --dbpass=" . escapeshellarg($dbPassword) . " --dbhost=localhost --skip-check --force 2>&1", + 'Install WordPress core' => "cd {$quotedPath} && php wp-cli.phar core install --url={$quotedInstallUrl} --title={$quotedSiteTitle} --admin_user={$quotedAdminUsername} --admin_password={$quotedAdminPassword} --admin_email={$quotedAdminEmail} --skip-email 2>&1", + 'Set WordPress permalinks' => "cd {$quotedPath} && php wp-cli.phar rewrite structure '/%postname%/' 2>&1", + 'Remove WP-CLI' => "cd {$quotedPath} && rm -f wp-cli.phar 2>&1", + 'Verify WordPress files' => "test -f " . escapeshellarg($path . '/wp-load.php') . " && test -f " . escapeshellarg($path . '/wp-config.php') . " 2>&1", + ]; + + foreach ($commands as $step => $cmd) { + Log::info("Executing: {$cmd}"); + $result = $this->runtimeFor($account)->executeCommand($account, $cmd); + Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); + + if ($result['exit_code'] !== 0) { + Log::error("WordPress install command failed: {$cmd}", [ + 'step' => $step, + 'exit_code' => $result['exit_code'], + 'output' => $result['output'], + ]); + + throw new \RuntimeException( + 'WordPress installation failed. ' + . $this->formatInstallerCommandFailure($step, $result) + ); + } + } + } catch (\Throwable $exception) { + try { + $this->runtimeFor($account)->deleteDatabase($database); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to clean up WordPress database after install error', [ + 'database' => $database->name, + 'error' => $cleanupException->getMessage(), + ]); + } + + $database->delete(); + + throw $exception; + } + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPassword, + 'admin_url' => rtrim($installUrl, '/') . '/wp-admin', + 'admin_path' => 'wp-admin', + 'admin_user' => $adminUsername, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + 'app_version' => null, + ]; + } + + private function installJoomla(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array + { + Log::info("Installing Joomla to: {$path}"); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases()->count() >= $maxDatabases) { + throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); + } + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); + $dbName = $prefix . '_' . $dbSuffix; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; + $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; + $adminPassword = Str::password(16, true, true, false, false); + $siteTitle = $site->domain; + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); + + $quotedPath = escapeshellarg($path); + + $commands = [ + 'Download Joomla package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://downloads.joomla.org/cms/joomla5/5-2-4/Joomla_5-2-4-Stable-Full_Package.zip -o joomla.zip 2>&1", + 'Extract Joomla package' => "cd {$quotedPath} && unzip -q joomla.zip 2>&1", + 'Remove installation archive' => "cd {$quotedPath} && rm -f joomla.zip 2>&1", + 'Set directory permissions' => "cd {$quotedPath} && find . -type d -exec chmod 755 {} \\; && find . -type f -exec chmod 644 {} \\; 2>&1", + 'Set writable directories' => "cd {$quotedPath} && chmod -R 775 cache tmp administrator/cache administrator/logs 2>&1", + 'Set root writable for config' => "cd {$quotedPath} && chmod 775 . 2>&1", + 'Initialize autoload cache' => "cd {$quotedPath} && echo ' administrator/cache/autoload_psr4.php && echo ' cache/autoload_psr4.php 2>&1", + ]; + + foreach ($commands as $step => $cmd) { + Log::info("Executing: {$cmd}"); + $result = $this->runtimeFor($account)->executeCommand($account, $cmd); + Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); + + if ($result['exit_code'] !== 0) { + throw new \RuntimeException( + 'Joomla installation failed. ' . $this->formatInstallerCommandFailure($step, $result) + ); + } + } + + $this->runtimeFor($account)->setWebServerGroupOwnership($account, $path); + } catch (\Throwable $exception) { + try { + $this->runtimeFor($account)->deleteDatabase($database); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to clean up Joomla database after install error', ['error' => $cleanupException->getMessage()]); + } + $database->delete(); + throw $exception; + } + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPassword, + 'admin_url' => rtrim($installUrl, '/') . '/administrator', + 'admin_path' => 'administrator', + 'admin_user' => $adminUsername, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + 'app_version' => '5.2.4', + 'requires_setup' => true, + ]; + } + + private function installDrupal(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array + { + Log::info("Installing Drupal to: {$path}"); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases()->count() >= $maxDatabases) { + throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); + } + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); + $dbName = $prefix . '_' . $dbSuffix; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; + $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; + $adminPassword = Str::password(16, true, true, false, false); + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); + + $quotedPath = escapeshellarg($path); + + $commands = [ + 'Download Drupal package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://ftp.drupal.org/files/projects/drupal-11.1.1.tar.gz -o drupal.tar.gz 2>&1", + 'Extract Drupal package' => "cd {$quotedPath} && tar -xzf drupal.tar.gz --strip-components=1 2>&1", + 'Remove installation archive' => "cd {$quotedPath} && rm -f drupal.tar.gz 2>&1", + 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", + ]; + + foreach ($commands as $step => $cmd) { + Log::info("Executing: {$cmd}"); + $result = $this->runtimeFor($account)->executeCommand($account, $cmd); + Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); + + if ($result['exit_code'] !== 0) { + throw new \RuntimeException( + 'Drupal installation failed. ' . $this->formatInstallerCommandFailure($step, $result) + ); + } + } + } catch (\Throwable $exception) { + try { + $this->runtimeFor($account)->deleteDatabase($database); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to clean up Drupal database after install error', ['error' => $cleanupException->getMessage()]); + } + $database->delete(); + throw $exception; + } + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPassword, + 'admin_url' => rtrim($installUrl, '/') . '/admin', + 'admin_path' => 'admin', + 'admin_user' => $adminUsername, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + 'app_version' => '11.1.1', + 'requires_setup' => true, + ]; + } + + private function installOpenCart(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array + { + Log::info("Installing OpenCart to: {$path}"); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases()->count() >= $maxDatabases) { + throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); + } + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); + $dbName = $prefix . '_' . $dbSuffix; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; + $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; + $adminPassword = Str::password(16, true, true, false, false); + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); + + $quotedPath = escapeshellarg($path); + + $commands = [ + 'Download OpenCart package' => "cd {$quotedPath} && curl -fLsS --retry 2 --connect-timeout 30 https://github.com/opencart/opencart/releases/download/4.0.2.3/opencart-4.0.2.3.zip -o opencart.zip 2>&1", + 'Extract OpenCart package' => "cd {$quotedPath} && unzip -q opencart.zip 2>&1", + 'Move files to root' => "cd {$quotedPath} && mv upload/* . 2>&1 || true", + 'Remove extra directories' => "cd {$quotedPath} && rm -rf upload opencart.zip 2>&1", + 'Rename config files' => "cd {$quotedPath} && cp config-dist.php config.php && cp admin/config-dist.php admin/config.php 2>&1", + 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", + ]; + + foreach ($commands as $step => $cmd) { + Log::info("Executing: {$cmd}"); + $result = $this->runtimeFor($account)->executeCommand($account, $cmd); + Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); + + if ($result['exit_code'] !== 0 && !str_contains($step, 'Move files')) { + throw new \RuntimeException( + 'OpenCart installation failed. ' . $this->formatInstallerCommandFailure($step, $result) + ); + } + } + } catch (\Throwable $exception) { + try { + $this->runtimeFor($account)->deleteDatabase($database); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to clean up OpenCart database after install error', ['error' => $cleanupException->getMessage()]); + } + $database->delete(); + throw $exception; + } + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPassword, + 'admin_url' => rtrim($installUrl, '/') . '/admin', + 'admin_path' => 'admin', + 'admin_user' => $adminUsername, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + 'app_version' => '4.0.2.3', + 'requires_setup' => true, + ]; + } + + private function installMagento(HostingAccount $account, HostedSite $site, string $path, string $installUrl): array + { + Log::info("Installing Magento to: {$path}"); + + $maxDatabases = $account->resource_limits['max_databases'] ?? $account->product?->max_databases ?? 5; + if ($account->databases()->count() >= $maxDatabases) { + throw new \RuntimeException("You have reached the maximum number of databases ({$maxDatabases})."); + } + + $prefix = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 8); + $dbSuffix = substr(Str::lower(Str::random(6)), 0, 6); + $dbName = $prefix . '_' . $dbSuffix; + $dbUser = $dbName; + $dbPassword = Str::password(16, true, true, false, false); + $adminUsername = substr(preg_replace('/[^a-z0-9]/', '', strtolower($account->username)), 0, 32) ?: 'admin'; + $adminEmail = $account->user?->email ?: 'admin@' . $site->domain; + $adminPassword = Str::password(16, true, true, false, false); + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + try { + $this->runtimeFor($account)->createDatabase($database, $dbPassword); + $this->runtimeFor($account)->prepareDirectoryForUser($account, $path); + + $quotedPath = escapeshellarg($path); + + $commands = [ + 'Download Magento via Composer' => "cd {$quotedPath} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition . --no-interaction 2>&1 || curl -fLsS https://github.com/magento/magento2/archive/refs/tags/2.4.7.tar.gz -o magento.tar.gz && tar -xzf magento.tar.gz --strip-components=1 && rm -f magento.tar.gz 2>&1", + 'Set directory permissions' => "cd {$quotedPath} && chmod -R 755 . 2>&1", + ]; + + foreach ($commands as $step => $cmd) { + Log::info("Executing: {$cmd}"); + $result = $this->runtimeFor($account)->executeCommand($account, $cmd); + Log::info("Result: exit_code={$result['exit_code']}, output=" . substr($result['output'] ?? '', 0, 500)); + + if ($result['exit_code'] !== 0) { + throw new \RuntimeException( + 'Magento installation failed. ' . $this->formatInstallerCommandFailure($step, $result) + ); + } + } + } catch (\Throwable $exception) { + try { + $this->runtimeFor($account)->deleteDatabase($database); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to clean up Magento database after install error', ['error' => $cleanupException->getMessage()]); + } + $database->delete(); + throw $exception; + } + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPassword, + 'admin_url' => rtrim($installUrl, '/') . '/admin', + 'admin_path' => 'admin', + 'admin_user' => $adminUsername, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + 'app_version' => '2.4.7', + 'requires_setup' => true, + ]; + } + + private function phpMyAdminUrlForAccount(HostingAccount $account): ?string + { + $baseUrl = trim((string) config('hosting.shared.phpmyadmin_url', '')); + + if ($baseUrl === '') { + return null; + } + + // Support placeholders in the URL for per-account access + $url = str_replace( + ['{username}', '{account}', '{primary_domain}', '{node_ip}'], + [ + $account->username, + $account->username, + $account->primary_domain ?? '', + $account->node?->ip_address ?? '', + ], + $baseUrl + ); + + return rtrim($url, '/'); + } + + private function formatInstallerCommandFailure(string $step, array $result): string + { + $output = trim((string) ($result['output'] ?? '')); + + if ($output !== '') { + return "{$step} failed: {$output}"; + } + + $exitCode = $result['exit_code'] ?? 'unknown'; + + return "{$step} failed with exit code {$exitCode}."; + } + + private function installNodeJs(HostingAccount $account, string $path): void + { + $packageJson = json_encode([ + 'name' => 'my-node-app', + 'version' => '1.0.0', + 'main' => 'app.js', + 'scripts' => ['start' => 'node app.js'], + ], JSON_PRETTY_PRINT); + + $appJs = "const http = require('http');\n\nconst server = http.createServer((req, res) => {\n res.writeHead(200, {'Content-Type': 'text/html'});\n res.end('

Hello from Node.js!

');\n});\n\nserver.listen(process.env.PORT || 3000);"; + + $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($path)); + if ($result['exit_code'] !== 0) { + throw new \RuntimeException("Failed to create directory"); + } + + $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($packageJson) . " > " . escapeshellarg($path . "/package.json")); + $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($appJs) . " > " . escapeshellarg($path . "/app.js")); + } + + private function installPython(HostingAccount $account, string $path): void + { + $appPy = "from flask import Flask\n\napp = Flask(__name__)\n\n@app.route('/')\ndef hello():\n return '

Hello from Python!

'\n\nif __name__ == '__main__':\n app.run()"; + + $requirements = "flask>=2.0.0\ngunicorn>=20.0.0"; + + $result = $this->runtimeFor($account)->executeCommand($account, "mkdir -p " . escapeshellarg($path)); + if ($result['exit_code'] !== 0) { + throw new \RuntimeException("Failed to create directory"); + } + + $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($appPy) . " > " . escapeshellarg($path . "/app.py")); + $this->runtimeFor($account)->executeCommand($account, "echo " . escapeshellarg($requirements) . " > " . escapeshellarg($path . "/requirements.txt")); + } + + // ==================== SETTINGS ==================== + + public function settings(Request $request, HostingAccount $account): View + { + $this->authorizeForRequestUser($request, 'viewSettings', $account); + $account->load(['node', 'product']); + $membership = $this->membershipForRequestUser($request, $account); + + return view('hosting.panel.settings', [ + 'account' => $account, + 'teamMembership' => $membership, + 'canChangePassword' => Gate::forUser($request->user())->check('changePassword', $account), + ]); + } + + public function changePassword(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'changePassword', $account); + $account->load(['node']); + + $validated = $request->validate([ + 'password' => 'required|string|min:8|confirmed', + ]); + + try { + $this->runtimeFor($account)->changePassword($account, $validated['password']); + + return back()->with('success', 'Password changed successfully. Use this password for SFTP access.'); + } catch (\Exception $e) { + Log::error("Failed to change password: " . $e->getMessage()); + return back()->with('error', 'Failed to change password. Please try again.'); + } + } + + public function saveSshKey(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'viewSettings', $account); + $account->load(['node']); + $membership = $this->requireDeveloperMembershipForRequestUser($request, $account); + + $validated = $request->validate([ + 'ssh_public_key' => ['required', 'string', 'max:10000', function (string $attribute, mixed $value, \Closure $fail): void { + if (! HostingAccountMember::looksLikeSshPublicKey((string) $value)) { + $fail('Enter a valid OpenSSH public key.'); + } + }], + ]); + + $sshPublicKey = HostingAccountMember::normalizeSshPublicKey($validated['ssh_public_key']); + + try { + $this->runtimeFor($account)->installTeamMemberSshKey($account, $membership->sshKeyMarker(), $sshPublicKey); + + $membership->forceFill([ + 'ssh_public_key' => $sshPublicKey, + 'ssh_key_installed_at' => now(), + ])->save(); + + return back()->with('success', 'SSH key saved. You can now connect over SFTP.'); + } catch (\Throwable $e) { + Log::error('Failed to save developer SSH key: '.$e->getMessage(), [ + 'hosting_account_id' => $account->id, + 'user_id' => $request->user()?->id, + ]); + + return back()->with('error', 'Failed to save SSH key. Please try again.'); + } + } + + public function deleteSshKey(Request $request, HostingAccount $account): RedirectResponse + { + $this->authorizeForRequestUser($request, 'viewSettings', $account); + $account->load(['node']); + $membership = $this->requireDeveloperMembershipForRequestUser($request, $account); + + try { + $this->runtimeFor($account)->removeTeamMemberSshKey($account, $membership->sshKeyMarker()); + + $membership->forceFill([ + 'ssh_public_key' => null, + 'ssh_key_installed_at' => null, + ])->save(); + + return back()->with('success', 'SSH key removed. SFTP access through your team membership has been revoked.'); + } catch (\Throwable $e) { + Log::error('Failed to remove developer SSH key: '.$e->getMessage(), [ + 'hosting_account_id' => $account->id, + 'user_id' => $request->user()?->id, + ]); + + return back()->with('error', 'Failed to remove SSH key. Please try again.'); + } + } + + // ==================== HELPERS ==================== + + private function runtimeFor(HostingAccount $account): PanelRuntimeInterface + { + return $this->panelRuntimeResolver->forSubject($account); + } + + private function authorizeForRequestUser(Request $request, string $ability, HostingAccount $account): void + { + Gate::forUser($request->user())->authorize($ability, $account); + } + + private function membershipForRequestUser(Request $request, HostingAccount $account): ?HostingAccountMember + { + $user = $request->user(); + + if (! $user || (int) $account->user_id === (int) $user->id) { + return null; + } + + return $account->teamMembers() + ->where('user_id', $user->id) + ->first(); + } + + private function requireDeveloperMembershipForRequestUser(Request $request, HostingAccount $account): HostingAccountMember + { + $membership = $this->membershipForRequestUser($request, $account); + + abort_if(! $membership, 403); + + return $membership; + } + + private function ensureDatabaseBelongsToAccount(HostedDatabase $database, HostingAccount $account): void + { + abort_if((int) $database->hosting_account_id !== (int) $account->id, 403); + } + + private function ensureSiteBelongsToAccount(HostedSite $site, HostingAccount $account): void + { + abort_if((int) $site->hosting_account_id !== (int) $account->id, 403); + } + + private function getAccountStats(HostingAccount $account): array + { + try { + return $this->runtimeFor($account)->getUsageStats($account); + } catch (\Exception $e) { + Log::error("Failed to get account stats: " . $e->getMessage()); + return [ + 'disk_used_bytes' => 0, + 'database_size_bytes' => 0, + 'file_size_bytes' => 0, + ]; + } + } + + private function guardWritableAccount(HostingAccount $account): ?JsonResponse + { + if ($account->status === HostingAccount::STATUS_SUSPENDED) { + return response()->json([ + 'success' => false, + 'error' => 'This hosting account is suspended.', + ], 423); + } + + if ($account->uploadsAreRestricted()) { + return response()->json([ + 'success' => false, + 'error' => 'Uploads are temporarily restricted because the inode limit has been reached.', + ], 429); + } + + return null; + } + + private function listFiles(HostingAccount $account, string $path): array + { + $fullPath = "/home/{$account->username}{$path}"; + + $result = $this->runtimeFor($account)->executeCommand( + $account, + "ls -la " . escapeshellarg($fullPath) . " 2>/dev/null | tail -n +2" + ); + + if ($result['exit_code'] !== 0) { + return []; + } + + $files = []; + $lines = explode("\n", trim($result['output'])); + + foreach ($lines as $line) { + if (empty($line)) continue; + + $parts = preg_split('/\s+/', $line, 9); + if (count($parts) < 9) continue; + + $name = $parts[8]; + if ($name === '.' || $name === '..') continue; + + $isDir = $parts[0][0] === 'd'; + $permissions = $parts[0]; + $size = (int) $parts[4]; + $modified = "{$parts[5]} {$parts[6]} {$parts[7]}"; + + $files[] = [ + 'name' => $name, + 'path' => rtrim($path, '/') . '/' . $name, + 'type' => $isDir ? 'folder' : 'file', + 'size' => $size, + 'size_formatted' => $this->formatBytes($size), + 'permissions' => $permissions, + 'modified' => $modified, + 'extension' => $isDir ? null : pathinfo($name, PATHINFO_EXTENSION), + ]; + } + + usort($files, function ($a, $b) { + if ($a['type'] !== $b['type']) { + return $a['type'] === 'folder' ? -1 : 1; + } + return strcasecmp($a['name'], $b['name']); + }); + + return $files; + } + + private function sanitizePath(string $path): string + { + $path = '/' . ltrim($path, '/'); + $path = preg_replace('#/\.\.#', '', $path); + $path = preg_replace('#\.\./#', '', $path); + $path = preg_replace('#/+#', '/', $path); + + if ($path === '/') { + return '/public_html'; + } + + return $path; + } + + private function resolveTerminalPath(string $path, string $currentPath = '/public_html'): string + { + $path = trim($path); + $currentPath = trim($currentPath); + if ($currentPath === '') { + $currentPath = '/public_html'; + } + + if ($path === '') { + return $currentPath; + } + + if ($path === '~') { + return '/'; + } + + if (str_starts_with($path, '~/')) { + $path = substr($path, 1); + } + + $candidate = str_starts_with($path, '/') + ? $path + : rtrim($currentPath, '/').'/'.$path; + + $segments = explode('/', $candidate); + $normalized = []; + + foreach ($segments as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + array_pop($normalized); + continue; + } + + $normalized[] = $segment; + } + + $resolvedPath = '/'.implode('/', $normalized); + + return $resolvedPath === '' ? '/' : $resolvedPath; + } + + private function terminalWorkingDirectory(HostingAccount $account, string $path): string + { + $relativePath = $this->resolveTerminalPath($path); + + return $relativePath === '/' + ? "/home/{$account->username}" + : "/home/{$account->username}{$relativePath}"; + } + + private function assertTerminalDirectoryExists(HostingAccount $account, string $path): void + { + $fullPath = $this->terminalWorkingDirectory($account, $path); + $result = $this->runtimeFor($account)->executeCommand( + $account, + '[ -d '.escapeshellarg($fullPath).' ] && printf yes || printf no' + ); + + if (($result['exit_code'] ?? 1) !== 0 || trim((string) ($result['output'] ?? '')) !== 'yes') { + throw new \RuntimeException('Directory does not exist.'); + } + } + + private function getBreadcrumbs(string $path): array + { + $parts = explode('/', trim($path, '/')); + $breadcrumbs = [['name' => 'Home', 'path' => '/public_html']]; + + if ($path === '/') { + return [['name' => 'Home', 'path' => '/']]; + } + + if (! str_starts_with($path, '/public_html')) { + $breadcrumbs = [['name' => 'Home', 'path' => '/']]; + } + + $currentPath = ''; + foreach ($parts as $part) { + if (empty($part)) continue; + $currentPath .= '/' . $part; + $breadcrumbs[] = ['name' => $part, 'path' => $currentPath]; + } + + return $breadcrumbs; + } + + private function formatBytes(int $bytes): string + { + if ($bytes >= 1073741824) { + return number_format($bytes / 1073741824, 2) . ' GB'; + } elseif ($bytes >= 1048576) { + return number_format($bytes / 1048576, 2) . ' MB'; + } elseif ($bytes >= 1024) { + return number_format($bytes / 1024, 2) . ' KB'; + } + return $bytes . ' B'; + } +} diff --git a/app/Http/Controllers/Hosting/HostingProductController.php b/app/Http/Controllers/Hosting/HostingProductController.php new file mode 100644 index 0000000..5d62bc7 --- /dev/null +++ b/app/Http/Controllers/Hosting/HostingProductController.php @@ -0,0 +1,648 @@ +renderHostingPage($request, HostingProduct::TYPE_SINGLE_DOMAIN, 'Single Domain Hosting'); + } + + public function multiDomain(Request $request): View + { + return $this->renderHostingPage($request, HostingProduct::TYPE_MULTI_DOMAIN, 'Multi Domain Hosting'); + } + + public function wordpress(Request $request): View + { + return $this->renderHostingPage($request, HostingProduct::TYPE_WORDPRESS, 'WordPress Hosting'); + } + + public function vps(Request $request): View + { + return $this->renderServerPage($request, HostingProduct::TYPE_VPS, 'VPS'); + } + + public function dedicated(Request $request): View + { + return $this->renderServerPage($request, HostingProduct::TYPE_DEDICATED, 'Dedicated Server'); + } + + private function renderHostingPage(Request $request, string $type, string $title): View + { + $user = $request->user(); + + // New Hosting Orders + $orders = CustomerHostingOrder::query() + ->forUser($user->id) + ->whereHas('product', fn ($q) => $q->ofType($type)) + ->with(['product', 'domain', 'hostingAccount', 'hostingNode']) + ->latest() + ->get(); + + // Admin-assigned Hosting Accounts + $sharedAccountIds = HostingAccountMember::query() + ->where('user_id', $user->id) + ->pluck('hosting_account_id'); + + $hostingAccounts = HostingAccount::query() + ->where(function ($query) use ($user, $sharedAccountIds) { + $query->where('user_id', $user->id); + + if ($sharedAccountIds->isNotEmpty()) { + $query->orWhereIn('id', $sharedAccountIds); + } + }) + ->whereHas('product', fn ($q) => $q->ofType($type)) + ->with(['product', 'sites']) + ->latest() + ->get(); + + // RC Hosting Orders (Legacy) + $legacyHostingType = $this->getLegacyHostingTypeForType($type); + $rcCategory = $this->getRcCategoryForType($type); + $rcHostingOrders = collect(); + $rcServiceOrders = collect(); + + if ($legacyHostingType) { + $rcHostingOrders = HostingOrder::query() + ->where('user_id', $user->id) + ->where('hosting_type', $legacyHostingType) + ->latest() + ->get(); + } + + if ($rcCategory && Schema::hasTable('rc_service_orders')) { + $rcServiceOrders = RcServiceOrder::query() + ->where('user_id', $user->id) + ->where('category', $rcCategory) + ->visibleToCustomer() + ->latest() + ->get(); + } + + $marketingCategory = $this->getMarketingOrderCategory($type); + + // Native form data for in-dashboard ordering + $nativeForm = $this->nativeHostingProductForm($type); + $userDomains = $this->getUserDomains($user); + + return view('hosting.type', [ + 'title' => $title, + 'type' => $type, + 'orders' => $orders, + 'hostingAccounts' => $hostingAccounts, + 'rcHostingOrders' => $rcHostingOrders, + 'rcServiceOrders' => $rcServiceOrders, + 'marketingCategory' => $marketingCategory, + 'nativeForm' => $nativeForm, + 'userDomains' => $userDomains, + ]); + } + + private function renderServerPage(Request $request, string $type, string $title): View + { + $user = $request->user(); + $serverOrders = app(ServerOrderService::class); + + // New Hosting Orders + $orders = CustomerHostingOrder::query() + ->forUser($user->id) + ->whereHas('product', fn ($q) => $q->ofType($type)) + ->with(['product', 'vpsInstance']) + ->latest() + ->get(); + + // RC Service Orders (Legacy) + $rcCategory = $this->getRcCategoryForType($type); + $rcServiceOrders = collect(); + + if ($rcCategory && Schema::hasTable('rc_service_orders')) { + $rcServiceOrders = RcServiceOrder::query() + ->where('user_id', $user->id) + ->where('category', $rcCategory) + ->visibleToCustomer() + ->latest() + ->get(); + } + + $marketingCategory = $this->getMarketingOrderCategory($type); + + // Native form data for in-dashboard ordering + $nativeForm = $this->nativeHostingProductForm($type); + $userDomains = $this->getUserDomains($user); + $products = HostingProduct::query() + ->active() + ->visible() + ->where('type', $type) + ->ordered() + ->get(); + + $serverOrderPayloads = []; + foreach ($products as $product) { + $serverOrderPayloads[$product->id] = $serverOrders->frontendPayload($product); + } + + $billingCycles = [ + CustomerHostingOrder::CYCLE_MONTHLY => 'Monthly', + CustomerHostingOrder::CYCLE_QUARTERLY => 'Quarterly (3 months)', + CustomerHostingOrder::CYCLE_SEMIANNUAL => 'Semiannual (6 months)', + CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)', + ]; + + return view('hosting.server-type', [ + 'title' => $title, + 'type' => $type, + 'orders' => $orders, + 'rcServiceOrders' => $rcServiceOrders, + 'marketingCategory' => $marketingCategory, + 'nativeForm' => $nativeForm, + 'serverOrderPayloads' => $serverOrderPayloads, + 'billingCycles' => $billingCycles, + 'userDomains' => $userDomains, + ]); + } + + public function showAccount( + Request $request, + HostingAccount $account, + ?UpsellRecommendationService $upsells = null, + ?HostingRenewalCheckoutService $renewals = null + ): View + { + Gate::forUser($request->user())->authorize('viewAccount', $account); + $upsells ??= app(UpsellRecommendationService::class); + $renewals ??= app(HostingRenewalCheckoutService::class); + + $account->load(['product', 'user', 'node', 'sites']); + $maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1; + $freeEmailAllowance = $account->freeEmailAllowance(); + $mailboxes = $account->loadedMailboxes(); + $mailboxCount = $mailboxes->count(); + $extraMailboxCount = $freeEmailAllowance === null ? 0 : max($mailboxCount - $freeEmailAllowance, 0); + + return view('hosting.account', [ + 'account' => $account, + 'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(), + 'maxDomains' => $maxDomains, + 'canLinkMoreDomains' => $account->sites->count() < $maxDomains, + 'ownedDomains' => $this->getUserDomains($request->user()), + 'emailUsage' => [ + 'mailbox_count' => $mailboxCount, + 'free_allowance' => $freeEmailAllowance, + 'extra_count' => $extraMailboxCount, + 'paid_count' => $mailboxes->where('is_paid', true)->count(), + 'extra_total' => round($extraMailboxCount * 5, 2), + ], + 'upsellRecommendations' => $upsells->recommendationsForAccount($account), + 'renewalOptions' => $account->canBeRenewed() ? $renewals->availableRenewalOptions($account) : [], + ]); + } + + public function changeAccountPlan(Request $request, HostingAccount $account, HostingPlanChangeService $planChanges): RedirectResponse + { + Gate::forUser($request->user())->authorize('viewAccount', $account); + + $validated = $request->validate([ + 'target_product_id' => ['required', 'integer', 'exists:hosting_products,id'], + ]); + + $account->load(['product', 'latestOrder', 'sites', 'databases', 'node']); + $targetProduct = HostingProduct::query()->findOrFail($validated['target_product_id']); + + try { + $quote = $planChanges->quote($account, $targetProduct); + $result = $planChanges->apply($account, $targetProduct, $request->user()); + } catch (ValidationException $exception) { + return redirect() + ->route('hosting.accounts.show', $account) + ->withErrors($exception->errors()) + ->with('error', $exception->getMessage()); + } catch (\Throwable $exception) { + report($exception); + + return redirect() + ->route('hosting.accounts.show', $account) + ->with('error', 'Unable to change the hosting plan right now. Please try again.'); + } + + $message = match ($quote['direction']) { + HostingPlanChange::DIRECTION_UPGRADE => $quote['charge_amount'] > 0 + ? sprintf( + 'Plan upgraded to %s. A GHS %s invoice has been added for the prorated difference.', + $targetProduct->name, + number_format((float) $quote['charge_amount'], 2) + ) + : sprintf('Plan upgraded to %s.', $targetProduct->name), + HostingPlanChange::DIRECTION_DOWNGRADE => $quote['credit_amount'] > 0 + ? sprintf( + 'Plan changed to %s. A GHS %s credit has been recorded for the unused balance.', + $targetProduct->name, + number_format((float) $quote['credit_amount'], 2) + ) + : sprintf('Plan changed to %s.', $targetProduct->name), + default => 'Plan updated successfully.', + }; + + return redirect() + ->route('hosting.accounts.show', $result['account']) + ->with('success', $message); + } + + public function renewAccount(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse|JsonResponse + { + Gate::forUser($request->user())->authorize('viewAccount', $account); + + $durationMonths = $request->has('duration_months') + ? (int) $request->input('duration_months') + : null; + + try { + $checkout = $renewals->beginCheckout( + $account, + $request->user(), + route('hosting.accounts.renew.callback', $account), + $durationMonths + ); + } catch (ValidationException $exception) { + if ($request->wantsJson()) { + return response()->json(['error' => $exception->getMessage()], 422); + } + return back()->withErrors($exception->errors())->with('error', $exception->getMessage()); + } catch (\Throwable $exception) { + report($exception); + if ($request->wantsJson()) { + return response()->json(['error' => 'Unable to start renewal payment. Please try again.'], 422); + } + return back()->with('error', 'Unable to start renewal payment. Please try again.'); + } + + if ($request->wantsJson()) { + return response()->json(['checkout_url' => $checkout['authorization_url']]); + } + + return redirect()->away($checkout['authorization_url']); + } + + public function renewAccountCallback(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse + { + Gate::forUser($request->user())->authorize('viewAccount', $account); + + $reference = (string) $request->query('reference', ''); + if ($reference === '') { + return redirect() + ->route('hosting.accounts.show', $account) + ->with('error', 'Missing renewal payment reference.'); + } + + try { + $invoice = $renewals->completeCheckout($account, $request->user(), $reference); + } catch (ValidationException $exception) { + return redirect() + ->route('hosting.accounts.show', $account) + ->withErrors($exception->errors()) + ->with('error', $exception->getMessage()); + } catch (\Throwable $exception) { + report($exception); + + return redirect() + ->route('hosting.accounts.show', $account) + ->with('error', 'Unable to verify renewal payment. If you were charged, contact support with your payment reference.'); + } + + $months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0); + + return redirect() + ->route('hosting.accounts.show', $account) + ->with('success', "Hosting renewal payment confirmed. Your account has been renewed for {$months} month(s)."); + } + + public function show(Request $request, CustomerHostingOrder $order): View + { + abort_if($order->user_id !== $request->user()->id, 403); + + $order->load(['product', 'domain', 'hostingAccount', 'hostingNode', 'vpsInstance']); + + $viewName = $order->product?->requiresContabo() ? 'hosting.server-order' : 'hosting.order'; + + return view($viewName, [ + 'order' => $order, + ]); + } + + public function store(Request $request): JsonResponse|RedirectResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:hosting_products,id', + 'domain_name' => 'required|string|max:255', + 'billing_cycle' => 'required|in:monthly,quarterly,yearly,biennial', + ]); + + $product = HostingProduct::findOrFail($validated['product_id']); + + abort_unless($product->is_active && $product->is_visible, 404, 'Product not available'); + + $price = $product->getPriceForCycle($validated['billing_cycle']); + abort_unless($price !== null, 400, 'Invalid billing cycle for this product'); + + $order = CustomerHostingOrder::create([ + 'user_id' => $request->user()->id, + 'hosting_product_id' => $product->id, + 'domain_name' => $validated['domain_name'], + 'billing_cycle' => $validated['billing_cycle'], + 'amount_paid' => $price + ($product->setup_fee ?? 0), + 'currency' => $product->display_currency, + 'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL, + ]); + + $fulfillment = app(HostingOrderFulfillmentService::class); + $fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled']; + $customerMessage = $fulfilled + ? 'Order received. We are setting up your hosting.' + : 'Order received. Your order is pending.'; + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => $customerMessage, + 'order' => $order->fresh(), + 'redirect' => route('servers.orders.show', $order), + ]); + } + + return redirect()->route('servers.orders.show', $order) + ->with('success', $customerMessage); + } + + public function storeServer(Request $request): JsonResponse|RedirectResponse + { + $serverOrders = app(ServerOrderService::class); + $validated = $request->validate([ + 'product_id' => 'required|exists:hosting_products,id', + 'server_name' => 'required|string|max:255', + 'billing_cycle' => 'required|in:monthly,quarterly,semiannual,yearly', + 'region' => 'required|string|max:50', + 'image' => 'required|string|max:255', + 'license' => 'required|string|max:100', + 'application' => 'required|string|max:100', + 'default_user' => 'required|string|max:50', + 'additional_ip' => 'required|string|max:50', + 'private_networking' => 'required|string|max:50', + 'storage_type' => 'required|string|max:50', + 'object_storage' => 'required|string|max:50', + 'server_password' => ['required', 'string', 'confirmed', 'regex:'.$serverOrders->passwordPattern()], + ]); + + $product = HostingProduct::findOrFail($validated['product_id']); + + abort_unless($product->is_active && $product->is_visible, 404, 'Product not available'); + abort_unless($product->requiresContabo(), 400, 'Invalid product type for server order'); + + $selection = $serverOrders->selectionAndQuote($product, $validated); + $quote = $selection['quote']; + + $order = CustomerHostingOrder::create([ + 'user_id' => $request->user()->id, + 'hosting_product_id' => $product->id, + 'domain_name' => $validated['server_name'], + 'billing_cycle' => $validated['billing_cycle'], + 'amount_paid' => (float) $quote['total'], + 'currency' => $product->display_currency, + 'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL, + 'meta' => [ + 'region' => $selection['selection']['region'], + 'server_order' => [ + 'selection' => $selection['selection'], + 'selection_summary' => $selection['selection_summary'], + 'quote' => $quote, + 'panel_snapshot' => $selection['panel_snapshot'] ?? [], + 'server_password_encrypted' => encrypt($validated['server_password']), + ], + 'server_panel_runtime' => $selection['panel_snapshot'] ?? [], + ], + ]); + + $fulfillment = app(HostingOrderFulfillmentService::class); + $fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled']; + $customerMessage = $fulfilled + ? 'Order received. We are setting up your server.' + : 'Order received. Your order is pending.'; + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => $customerMessage, + 'order' => $order->fresh(), + 'redirect' => route('servers.orders.show', $order), + ]); + } + + return redirect()->route('servers.orders.show', $order) + ->with('success', $customerMessage); + } + + public function cancel(Request $request, CustomerHostingOrder $order): JsonResponse|RedirectResponse + { + abort_if($order->user_id !== $request->user()->id, 403); + abort_unless($order->canBeCancelled(), 400, 'This order cannot be cancelled'); + + $order->cancel($request->input('reason')); + + if ($request->wantsJson()) { + return response()->json([ + 'success' => true, + 'message' => 'Order cancelled successfully.', + ]); + } + + return back()->with('success', 'Order cancelled successfully.'); + } + + private function getLegacyHostingTypeForType(string $type): ?string + { + return match ($type) { + HostingProduct::TYPE_SINGLE_DOMAIN => HostingOrder::TYPE_SINGLE_DOMAIN, + HostingProduct::TYPE_MULTI_DOMAIN => HostingOrder::TYPE_MULTI_DOMAIN, + default => null, + }; + } + + private function getRcCategoryForType(string $type): ?string + { + return match ($type) { + HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting', + HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting', + HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting', + HostingProduct::TYPE_VPS => 'vps', + HostingProduct::TYPE_DEDICATED => 'dedicated-server', + default => null, + }; + } + + private function getMarketingOrderCategory(string $type): ?string + { + // Map internal hosting types to marketing order categories + return match ($type) { + HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting', + HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting', + HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting', + HostingProduct::TYPE_VPS => 'vps', + HostingProduct::TYPE_DEDICATED => 'dedicated-server', + default => null, + }; + } + + /** + * Build native form definition from HostingProduct records. + * + * @return array + */ + private function nativeHostingProductForm(string $productType): array + { + $products = HostingProduct::query() + ->active() + ->visible() + ->where('type', $productType) + ->ordered() + ->get(); + + $serverOrders = app(ServerOrderService::class); + + $packages = $products->map(function (HostingProduct $product) use ($serverOrders) { + if ($product->requiresContabo()) { + $payload = $serverOrders->frontendPayload($product); + $currency = strtoupper((string) ($payload['currency'] ?? $product->display_currency)); + $monthly = (float) data_get($payload, 'prices.monthly', 0); + $quarterly = (float) data_get($payload, 'prices.quarterly', 0); + $semiannual = (float) data_get($payload, 'prices.semiannual', 0); + $yearly = (float) data_get($payload, 'prices.yearly', 0); + $setupFees = (array) ($payload['setup_fees'] ?? []); + + $prices = []; + $totals = []; + + if ($monthly > 0) { + $prices['1'] = $currency.' '.number_format($monthly, 2); + $totals['1'] = $currency.' '.number_format($monthly + (float) ($setupFees['monthly'] ?? 0), 2); + } + if ($quarterly > 0) { + $prices['3'] = $currency.' '.number_format($quarterly / 3, 2); + $totals['3'] = $currency.' '.number_format($quarterly + (float) ($setupFees['quarterly'] ?? 0), 2); + } + if ($semiannual > 0) { + $prices['6'] = $currency.' '.number_format($semiannual / 6, 2); + $totals['6'] = $currency.' '.number_format($semiannual + (float) ($setupFees['semiannual'] ?? 0), 2); + } + if ($yearly > 0) { + $prices['12'] = $currency.' '.number_format($yearly / 12, 2); + $totals['12'] = $currency.' '.number_format($yearly + (float) ($setupFees['yearly'] ?? 0), 2); + } + + return [ + 'value' => (string) $product->id, + 'label' => $product->name, + 'summary' => $this->nativeProductSummary($product), + 'starting_price' => $currency.' '.number_format($monthly, 2), + 'starting_term' => '1', + 'starting_label' => $currency.' '.number_format($monthly, 2).'/mo', + 'prices' => $prices, + 'totals' => $totals, + 'pricing_source' => 'contabo_dynamic', + ]; + } + + $currency = $product->display_currency; + $monthly = $product->getPriceForCycle('monthly'); + $quarterly = $product->getPriceForCycle('quarterly'); + $yearly = $product->getPriceForCycle('yearly'); + $biennial = $product->getPriceForCycle('biennial'); + + $prices = []; + $totals = []; + + if ($monthly) { + $prices['1'] = $currency.' '.number_format($monthly, 2); + $totals['1'] = $currency.' '.number_format($monthly, 2); + } + if ($quarterly) { + $prices['3'] = $currency.' '.number_format($quarterly / 3, 2); + $totals['3'] = $currency.' '.number_format($quarterly, 2); + } + if ($yearly) { + $prices['12'] = $currency.' '.number_format($yearly / 12, 2); + $totals['12'] = $currency.' '.number_format($yearly, 2); + } + if ($biennial) { + $prices['24'] = $currency.' '.number_format($biennial / 24, 2); + $totals['24'] = $currency.' '.number_format($biennial, 2); + } + + $summary = $this->nativeProductSummary($product); + + return [ + 'value' => (string) $product->id, + 'label' => $product->name, + 'summary' => $summary, + 'starting_price' => $currency.' '.number_format($product->display_price_monthly, 2), + 'starting_term' => '1', + 'starting_label' => $currency.' '.number_format($product->display_price_monthly, 2).'/mo', + 'prices' => $prices, + 'totals' => $totals, + ]; + })->values()->all(); + + return [ + 'packages' => $packages, + 'has_packages' => $products->isNotEmpty(), + 'region' => 'Global', + 'requires_domain' => true, + 'supports_os' => false, + 'os_options' => [], + 'supports_addons' => false, + 'addon_options' => [], + 'supports_ssl_toggle' => false, + 'supports_dedicated_ip' => false, + ]; + } + + private function nativeProductSummary(HostingProduct $product): ?string + { + return $product->catalogSummary(); + } + + private function getUserDomains($user): \Illuminate\Support\Collection + { + return \App\Models\Domain::query() + ->where('user_id', $user->id) + ->whereNull('hosting_account_id') + ->whereDoesntHave('hostedSites') + ->where(function ($query) { + $query->where('source', 'purchased') + ->orWhereNotNull('email_domain_id'); + }) + ->orderBy('host') + ->get(['id', 'host', 'status', 'onboarding_state']); + } +} diff --git a/app/Http/Controllers/Hosting/HostingTerminalSessionController.php b/app/Http/Controllers/Hosting/HostingTerminalSessionController.php new file mode 100644 index 0000000..4253490 --- /dev/null +++ b/app/Http/Controllers/Hosting/HostingTerminalSessionController.php @@ -0,0 +1,159 @@ +authorize('useTerminal', $account); + $account->loadMissing('node'); + + $validated = $request->validate([ + 'path' => 'nullable|string|max:255', + 'cols' => 'nullable|integer|min:40|max:240', + 'rows' => 'nullable|integer|min:10|max:80', + ]); + + $session = $this->sessionManager->createSession( + $account, + (int) $request->user()->id, + (string) ($validated['path'] ?? '/public_html'), + (int) ($validated['cols'] ?? 120), + (int) ($validated['rows'] ?? 30), + ); + + return response()->json([ + 'session_id' => $session['session_id'], + 'status' => $session['status'], + ]); + } + + public function read(Request $request, HostingAccount $account, string $session): JsonResponse + { + $this->authorize('useTerminal', $account); + + try { + $payload = $this->sessionManager->readOutput( + $account, + (int) $request->user()->id, + $session, + (int) $request->integer('offset', 0), + ); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 404); + } + + return response()->json($payload); + } + + public function input(Request $request, HostingAccount $account, string $session): JsonResponse + { + $this->authorize('useTerminal', $account); + + $input = $this->extractTerminalInput($request); + + if ($input === null) { + return response()->json(['message' => 'Invalid terminal input payload.'], 422); + } + + if (strlen($input) > 8192) { + return response()->json(['message' => 'Terminal input payload is too large.'], 422); + } + + if ($input === '') { + return response()->json(['ok' => true]); + } + + try { + $this->sessionManager->appendInput( + $account, + (int) $request->user()->id, + $session, + $input, + ); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 404); + } + + return response()->json(['ok' => true]); + } + + public function resize(Request $request, HostingAccount $account, string $session): JsonResponse + { + $this->authorize('useTerminal', $account); + + $validated = $request->validate([ + 'cols' => 'required|integer|min:40|max:240', + 'rows' => 'required|integer|min:10|max:80', + ]); + + try { + $this->sessionManager->resizeSession( + $account, + (int) $request->user()->id, + $session, + (int) $validated['cols'], + (int) $validated['rows'], + ); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 404); + } + + return response()->json(['ok' => true]); + } + + public function close(Request $request, HostingAccount $account, string $session): JsonResponse + { + $this->authorize('useTerminal', $account); + + try { + $this->sessionManager->closeSession( + $account, + (int) $request->user()->id, + $session, + ); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 404); + } + + return response()->json(['ok' => true]); + } + + private function extractTerminalInput(Request $request): ?string + { + $input = $request->input('input'); + + if (is_string($input)) { + return $input; + } + + $raw = $request->getContent(); + + if (! is_string($raw)) { + return null; + } + + if ($raw === '') { + return ''; + } + + $decoded = json_decode($raw, true); + + if (is_array($decoded) && array_key_exists('input', $decoded) && is_string($decoded['input'])) { + return $decoded['input']; + } + + return $raw; + } +} diff --git a/app/Http/Controllers/Hosting/MailboxLinkBannerController.php b/app/Http/Controllers/Hosting/MailboxLinkBannerController.php new file mode 100644 index 0000000..2e488cc --- /dev/null +++ b/app/Http/Controllers/Hosting/MailboxLinkBannerController.php @@ -0,0 +1,17 @@ +session()->put('mailbox_link_banner_dismissed', true); + + return back(); + } +} diff --git a/app/Http/Controllers/Hosting/OverviewController.php b/app/Http/Controllers/Hosting/OverviewController.php new file mode 100644 index 0000000..a3e9e6d --- /dev/null +++ b/app/Http/Controllers/Hosting/OverviewController.php @@ -0,0 +1,53 @@ +user(); + $serverTypes = $this->serverTypes(); + + $orders = CustomerHostingOrder::query() + ->forUser($user->id) + ->whereHas('product', fn ($q) => $q->whereIn('type', $serverTypes)) + ->with('product') + ->latest() + ->get(); + + $activeOrders = $orders->where('status', CustomerHostingOrder::STATUS_ACTIVE); + $pendingOrders = $orders->whereIn('status', [ + CustomerHostingOrder::STATUS_PENDING_PAYMENT, + CustomerHostingOrder::STATUS_PENDING_APPROVAL, + CustomerHostingOrder::STATUS_APPROVED, + CustomerHostingOrder::STATUS_PROVISIONING, + ]); + + return view('servers.dashboard', [ + 'orders' => $orders, + 'activeCount' => $activeOrders->count(), + 'vpsCount' => $activeOrders->filter(fn (CustomerHostingOrder $order) => $order->product?->type === HostingProduct::TYPE_VPS)->count(), + 'dedicatedCount' => $activeOrders->filter(fn (CustomerHostingOrder $order) => $order->product?->type === HostingProduct::TYPE_DEDICATED)->count(), + 'pendingOrderCount' => $pendingOrders->count(), + 'recent' => $orders->take(8), + 'pendingOrders' => $pendingOrders->take(5), + ]); + } + + /** @return list */ + private function serverTypes(): array + { + return [ + HostingProduct::TYPE_VPS, + HostingProduct::TYPE_DEDICATED, + ]; + } +} diff --git a/app/Http/Controllers/Hosting/SearchController.php b/app/Http/Controllers/Hosting/SearchController.php new file mode 100644 index 0000000..7217c99 --- /dev/null +++ b/app/Http/Controllers/Hosting/SearchController.php @@ -0,0 +1,56 @@ +query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json(['results' => []]); + } + + return response()->json([ + 'results' => $this->resultsForUser($request->user(), $q), + ]); + } + + /** @return list */ + private function resultsForUser($user, string $q): array + { + $like = '%'.$q.'%'; + $results = []; + $serverTypes = [HostingProduct::TYPE_VPS, HostingProduct::TYPE_DEDICATED]; + + CustomerHostingOrder::query() + ->forUser($user->id) + ->whereHas('product', fn ($query) => $query->whereIn('type', $serverTypes)) + ->where(function ($query) use ($like) { + $query->where('domain_name', 'like', $like) + ->orWhere('server_ip', 'like', $like); + }) + ->with('product') + ->orderByDesc('updated_at') + ->limit(12) + ->get() + ->each(function (CustomerHostingOrder $order) use (&$results): void { + $panelUrl = $order->control_panel_url ?: route('servers.orders.show', $order); + $results[] = [ + 'type' => 'server', + 'title' => $order->domain_name ?: ($order->product?->name ?? 'Server #'.$order->id), + 'subtitle' => ($order->product?->name ?? 'Server').' · '.ucwords(str_replace('_', ' ', (string) $order->status)), + 'url' => $panelUrl, + ]; + }); + + return $results; + } +} diff --git a/app/Http/Controllers/Hosting/ServerPanelController.php b/app/Http/Controllers/Hosting/ServerPanelController.php new file mode 100644 index 0000000..7d0ac6d --- /dev/null +++ b/app/Http/Controllers/Hosting/ServerPanelController.php @@ -0,0 +1,299 @@ +authorizedOrder($request, $order); + $panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', [])); + + if ((bool) ($panelRuntime['managed_stack_candidate'] ?? false)) { + app(ServerAgentBootstrapService::class)->ensureBootstrap($order); + $order->refresh(); + $panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', [])); + } + + return view('hosting.server-panel.show', [ + 'order' => $order, + 'panelState' => (array) data_get($order->meta, 'server_panel', []), + 'panelRuntime' => $panelRuntime, + 'selectionSummary' => (array) data_get($order->meta, 'server_order.selection_summary', []), + 'serverAgent' => $order->serverAgent, + 'serverTasks' => $order->serverTasks()->latest('id')->limit(12)->get(), + 'serverSites' => $order->serverSites()->latest('updated_at')->limit(6)->get(), + 'serverDatabases' => $order->serverDatabases()->latest('updated_at')->limit(6)->get(), + 'serverFiles' => $order->serverFiles()->latest('last_synced_at')->limit(8)->get(), + ]); + } + + public function sync(Request $request, CustomerHostingOrder $order, ContaboProvider $provider): RedirectResponse + { + $order = $this->authorizedOrder($request, $order); + $instance = $provider->getInstance($this->providerInstanceId($order)); + + abort_if($instance === null, 404, 'Server instance not found.'); + + $this->updateOrderState($order, $instance); + + return back()->with('success', 'Server status refreshed.'); + } + + public function power(Request $request, CustomerHostingOrder $order, string $action, ContaboProvider $provider): RedirectResponse + { + $order = $this->authorizedOrder($request, $order); + + abort_unless(in_array($action, ['start', 'stop', 'reboot'], true), 404); + + $instanceId = $this->providerInstanceId($order); + $success = match ($action) { + 'start' => $provider->startInstance($instanceId), + 'stop' => $provider->stopInstance($instanceId), + 'reboot' => $provider->rebootInstance($instanceId), + }; + + if (! $success) { + return back()->with('error', 'Server action failed. Please try again.'); + } + + $meta = (array) ($order->meta ?? []); + $panelState = (array) ($meta['server_panel'] ?? []); + $panelState['last_action'] = [ + 'name' => $action, + 'at' => now()->toIso8601String(), + ]; + $panelState['power_status'] = match ($action) { + 'stop' => 'off', + default => 'on', + }; + + $order->update([ + 'meta' => array_merge($meta, ['server_panel' => $panelState]), + ]); + + return back()->with('success', ucfirst($action).' request sent successfully.'); + } + + public function queueDomain(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'], + 'document_root' => ['nullable', 'string', 'max:255'], + 'php_version' => ['nullable', 'string', 'in:8.0,8.1,8.2,8.3,8.4'], + ]); + + $runtime->queueDomainTask( + $order, + $validated['domain'], + $validated['document_root'] ?? null, + $validated['php_version'] ?? null, + ); + + return back()->with('success', 'Domain task queued for the server agent.'); + } + + public function queueDatabase(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_]+$/'], + ]); + + $runtime->queueDatabaseTask($order, $validated['name']); + + return back()->with('success', 'Database task queued for the server agent.'); + } + + public function queueSsl(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'], + ]); + + $runtime->queueSslTask($order, $validated['domain']); + + return back()->with('success', 'SSL task queued for the server agent.'); + } + + public function queueFile(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'operation' => ['required', 'string', 'in:list,read,write'], + 'path' => ['required', 'string', 'max:1000'], + 'content' => ['nullable', 'string'], + ]); + + $runtime->queueFileTask( + $order, + $validated['operation'], + $validated['path'], + $validated['content'] ?? null, + ); + + return back()->with('success', 'File task queued for the server agent.'); + } + + public function queueSiteDelete(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'], + ]); + + $runtime->queueSiteDeleteTask($order, $validated['domain']); + + return back()->with('success', 'Site removal task queued for the server agent.'); + } + + public function queuePhp(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'], + 'php_version' => ['required', 'string', 'in:8.0,8.1,8.2,8.3,8.4'], + ]); + + $runtime->queuePhpTask($order, $validated['domain'], $validated['php_version']); + + return back()->with('success', 'PHP configuration task queued for the server agent.'); + } + + public function queueCron(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'operation' => ['required', 'string', 'in:list,upsert,remove'], + 'name' => ['nullable', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'], + 'expression' => ['nullable', 'string', 'max:120'], + 'command' => ['nullable', 'string', 'max:1000'], + 'user' => ['nullable', 'string', 'in:web,root,www-data,nginx,wwwrun,apache'], + ]); + + if (($validated['operation'] ?? '') !== 'list') { + $request->validate([ + 'name' => ['required', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'], + ]); + } + + if (($validated['operation'] ?? '') === 'upsert') { + $request->validate([ + 'expression' => ['required', 'string', 'max:120'], + 'command' => ['required', 'string', 'max:1000'], + ]); + } + + $runtime->queueCronTask( + $order, + $validated['operation'], + $validated['name'] ?? null, + $validated['expression'] ?? null, + $validated['command'] ?? null, + $validated['user'] ?? 'web', + ); + + return back()->with('success', 'Cron task queued for the server agent.'); + } + + public function queueLog(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse + { + $order = $this->authorizedManagedOrder($request, $order); + + $validated = $request->validate([ + 'target' => ['required', 'string', 'in:nginx_access,nginx_error,php_fpm,mariadb,agent_service'], + 'domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'], + 'lines' => ['nullable', 'integer', 'min:20', 'max:500'], + ]); + + if (in_array($validated['target'], ['nginx_access', 'nginx_error'], true)) { + $request->validate([ + 'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'], + ]); + } + + $runtime->queueLogTask( + $order, + $validated['target'], + $validated['domain'] ?? null, + (int) ($validated['lines'] ?? 120), + ); + + return back()->with('success', 'Log read task queued for the server agent.'); + } + + private function authorizedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder + { + abort_if($order->user_id !== $request->user()->id, 403); + + $order->load('product'); + abort_unless($order->product?->requiresContabo(), 404); + abort_unless($this->ladillPanelEnabled($order), 404); + + return $order; + } + + private function ladillPanelEnabled(CustomerHostingOrder $order): bool + { + $license = (string) data_get($order->meta, 'server_order.selection.license', ''); + + return (string) data_get(config('hosting.server_order.licenses'), $license.'.panel') === 'ladill'; + } + + private function providerInstanceId(CustomerHostingOrder $order): string + { + $instanceId = (string) data_get($order->meta, 'contabo_instance_id', ''); + abort_if($instanceId === '', 404, 'Server instance is not provisioned yet.'); + + return $instanceId; + } + + /** + * @param array $instance + */ + private function updateOrderState(CustomerHostingOrder $order, array $instance): void + { + $meta = (array) ($order->meta ?? []); + $panelState = (array) ($meta['server_panel'] ?? []); + + $panelState = array_merge($panelState, [ + 'instance_status' => $instance['status'] ?? null, + 'power_status' => $instance['power_status'] ?? null, + 'region' => $instance['region'] ?? null, + 'datacenter' => $instance['datacenter'] ?? null, + 'image' => $instance['image'] ?? null, + 'last_synced_at' => now()->toIso8601String(), + ]); + + $order->update([ + 'server_ip' => $instance['ip_address'] ?? $order->server_ip, + 'meta' => array_merge($meta, ['server_panel' => $panelState]), + ]); + } + + private function authorizedManagedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder + { + $order = $this->authorizedOrder($request, $order); + abort_unless((bool) data_get($order->meta, 'server_panel_runtime.managed_stack_candidate', false), 404); + + return $order; + } +} diff --git a/app/Http/Controllers/Hosting/TeamController.php b/app/Http/Controllers/Hosting/TeamController.php new file mode 100644 index 0000000..ea7aeac --- /dev/null +++ b/app/Http/Controllers/Hosting/TeamController.php @@ -0,0 +1,114 @@ +where('account_id', $account->id) + ->with('member') + ->orderBy('status')->orderBy('email') + ->get(); + + return view('hosting.account.team', [ + 'account' => $account, + 'members' => $members, + 'canManage' => $this->canManage($request), + 'isOwner' => $request->user()->id === $account->id, + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($this->canManage($request), 403); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:admin,member'], + ]); + + $account = ladill_account(); + $email = strtolower($validated['email']); + + if ($email === strtolower($account->email)) { + return back()->withErrors(['email' => 'The owner is already on the account.']); + } + + $member = HostingTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]); + $member->role = $validated['role']; + if (! $member->exists) { + $member->status = HostingTeamMember::STATUS_INVITED; + $member->token = Str::random(40); + } + $member->save(); + + if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) { + HostingTeamMember::linkPendingInvitesFor($existing); + } + + return back()->with('success', $email.' invited.'); + } + + public function updateRole(Request $request, HostingTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $validated = $request->validate(['role' => ['required', 'in:admin,member']]); + $member->update(['role' => $validated['role']]); + + return back()->with('success', 'Role updated.'); + } + + public function destroy(Request $request, HostingTeamMember $member): RedirectResponse + { + abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403); + + $member->delete(); + + return back()->with('success', 'Member removed.'); + } + + /** Switch the acting account (own, or one you're a member of). */ + public function switchAccount(Request $request): RedirectResponse + { + $validated = $request->validate(['account' => ['required', 'integer']]); + + abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403); + + $request->session()->put('ladill_account', (int) $validated['account']); + + return redirect()->route('servers.dashboard'); + } + + private function canManage(Request $request): bool + { + $user = $request->user(); + $account = ladill_account(); + + if ($user->id === $account->id) { + return true; // owner + } + + return $user->memberships() + ->where('account_id', $account->id) + ->where('role', HostingTeamMember::ROLE_ADMIN) + ->exists(); + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d438a3a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,64 @@ +user() + ->notifications() + ->latest() + ->paginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function unread(Request $request): JsonResponse + { + $notifications = $request->user() + ->unreadNotifications() + ->latest() + ->take(10) + ->get() + ->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $n->data['title'] ?? 'Notification', + 'message' => $n->data['message'] ?? '', + 'icon' => $n->data['icon'] ?? 'bell', + 'url' => $n->data['url'] ?? null, + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/SetActingAccount.php b/app/Http/Middleware/SetActingAccount.php new file mode 100644 index 0000000..3eb297f --- /dev/null +++ b/app/Http/Middleware/SetActingAccount.php @@ -0,0 +1,38 @@ +user()) { + $accountId = (int) $request->session()->get('ladill_account', $user->id); + + if (! $user->canAccessAccount($accountId)) { + $accountId = $user->id; + $request->session()->put('ladill_account', $accountId); + } + + $account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user); + + $request->attributes->set('actingAccount', $account); + View::share('actingAccount', $account); + View::share('accessibleAccounts', $user->accessibleAccounts()); + } + + return $next($request); + } +} diff --git a/app/Jobs/DeactivateMailboxJob.php b/app/Jobs/DeactivateMailboxJob.php new file mode 100644 index 0000000..2693f40 --- /dev/null +++ b/app/Jobs/DeactivateMailboxJob.php @@ -0,0 +1,61 @@ +find($this->mailboxId); + if (! $mailbox) { + return; + } + + $hostingAccountId = $mailbox->hosting_account_id; + + try { + if ($provisioner->isConfigured()) { + $provisioner->deactivateMailbox($mailbox); + } + } catch (\Throwable $exception) { + $mailbox->update(['status' => 'failed']); + Log::error('DeactivateMailboxJob: Remote deactivation failed', [ + 'mailbox_id' => $mailbox->id, + 'address' => $mailbox->address, + 'error' => $exception->getMessage(), + ]); + + throw $exception; + } + + $mailbox->delete(); + + if ($hostingAccountId) { + $account = HostingAccount::query()->with(['product'])->find($hostingAccountId); + if ($account) { + $hostingMailboxes->syncAddonInvoice($account); + } + } + } +} diff --git a/app/Jobs/MailProvisioningJob.php b/app/Jobs/MailProvisioningJob.php new file mode 100644 index 0000000..52f33fc --- /dev/null +++ b/app/Jobs/MailProvisioningJob.php @@ -0,0 +1,120 @@ +with('mailboxes')->find($this->domainId); + if (! $domain) { + return; + } + + if (! $domain->isActiveForMail()) { + return; + } + + if (! $provisioner->isConfigured()) { + Log::info('MailProvisioningJob: Skipped — mail server DB not configured', ['domain_id' => $domain->id]); + return; + } + + $key = $dkim->ensureActive($domain); + + $beforeStatus = (string) $domain->status; + $beforeState = (string) ($domain->onboarding_state ?? ''); + + try { + // 1. Provision domain in Postfix/Dovecot DB + $provisioner->provisionDomain($domain); + + // 2. Provision each mailbox in Postfix/Dovecot DB + foreach ($domain->mailboxes as $mailbox) { + try { + $provisioner->provisionMailbox($mailbox); + } catch (\Throwable $e) { + Log::warning('MailProvisioningJob: Mailbox provision skipped (may need password)', [ + 'email' => $mailbox->address, + 'error' => $e->getMessage(), + ]); + } + } + + // 3. Deploy DKIM key to mail server + $provisioner->deployDkim($domain, $key); + + $this->markReady($domain); + $afterStatus = (string) $domain->status; + $afterState = (string) ($domain->onboarding_state ?? ''); + $this->logCheck($domain, 'success', 'Mail stack provisioned via direct DB', [], $beforeStatus, $beforeState, $afterStatus, $afterState); + } catch (\Throwable $exception) { + $this->markFailed($domain); + $afterStatus = (string) $domain->status; + $afterState = (string) ($domain->onboarding_state ?? ''); + $this->logCheck($domain, 'failed', $exception->getMessage(), [], $beforeStatus, $beforeState, $afterStatus, $afterState); + Log::error('Mail provisioning failed', ['domain_id' => $domain->id, 'error' => $exception->getMessage()]); + throw $exception; + } + } + + private function markReady(Domain $domain): void + { + $domain->update([ + 'mail_ready_at' => $domain->mail_ready_at ?? now(), + 'active_at' => $domain->active_at ?? now(), + 'status' => 'verified', + 'onboarding_state' => Domain::STATE_ACTIVE, + ]); + } + + private function markFailed(Domain $domain): void + { + $domain->update([ + 'status' => 'verifying', + 'onboarding_state' => Domain::STATE_MAIL_READY, + ]); + } + + private function logCheck(Domain $domain, string $result, string $notes, array $payload = []): void + { + $stateBefore = $stateAfter = (string) ($domain->onboarding_state ?? ''); + $statusBefore = $statusAfter = (string) $domain->status; + + if (! empty($payload)) { + $notes .= ' '.json_encode($payload); + } + + DomainNsCheck::create([ + 'domain_id' => $domain->id, + 'check_type' => 'mail_provision', + 'result' => $result, + 'notes' => $notes.(! empty($payload) ? ' '.json_encode($payload) : ''), + 'status_before' => $statusBefore, + 'status_after' => $statusAfter, + 'state_before' => $stateBefore, + 'state_after' => $stateAfter, + 'checked_at' => now(), + ]); + } +} diff --git a/app/Jobs/MigrateHostingAccountJob.php b/app/Jobs/MigrateHostingAccountJob.php new file mode 100644 index 0000000..56b5d3d --- /dev/null +++ b/app/Jobs/MigrateHostingAccountJob.php @@ -0,0 +1,109 @@ +find($this->accountId); + $destinationNode = HostingNode::find($this->destinationNodeId); + + if (!$account) { + Log::error("Migration job failed: Account {$this->accountId} not found"); + return; + } + + if (!$destinationNode) { + Log::error("Migration job failed: Destination node {$this->destinationNodeId} not found"); + return; + } + + $sourceNode = $account->node; + $sourceNodeId = $sourceNode?->id; + + Log::info("Starting migration job for account {$account->username}", [ + 'account_id' => $account->id, + 'source_node' => $sourceNode?->name, + 'destination_node' => $destinationNode->name, + 'initiated_by' => $this->initiatedBy, + ]); + + try { + // Update account status to migrating + $account->update(['status' => 'migrating']); + + // Perform the migration + $result = $provider->migrateAccount($account, $destinationNode, function ($message) use ($account) { + Log::info("Migration progress [{$account->username}]: {$message}"); + }); + + // Update account with new node + DB::transaction(function () use ($account, $destinationNode, $sourceNodeId) { + $account->update([ + 'hosting_node_id' => $destinationNode->id, + 'status' => 'active', + ]); + + // Update node account counts + $destinationNode->incrementAccountCount(); + + if ($sourceNodeId) { + HostingNode::where('id', $sourceNodeId)->decrement('current_accounts'); + } + }); + + Log::info("Migration completed successfully for account {$account->username}", [ + 'account_id' => $account->id, + 'new_node' => $destinationNode->name, + ]); + + } catch (\Throwable $e) { + Log::error("Migration failed for account {$account->username}: " . $e->getMessage(), [ + 'account_id' => $account->id, + 'exception' => $e, + ]); + + // Revert status + $account->update(['status' => 'active']); + + throw $e; + } + } + + public function failed(\Throwable $exception): void + { + Log::error("Migration job failed permanently for account {$this->accountId}", [ + 'destination_node' => $this->destinationNodeId, + 'error' => $exception->getMessage(), + ]); + + // Try to revert account status + $account = HostingAccount::find($this->accountId); + if ($account && $account->status === 'migrating') { + $account->update(['status' => 'active']); + } + } +} diff --git a/app/Jobs/ProvisionContaboInfrastructureJob.php b/app/Jobs/ProvisionContaboInfrastructureJob.php new file mode 100644 index 0000000..8e3dcec --- /dev/null +++ b/app/Jobs/ProvisionContaboInfrastructureJob.php @@ -0,0 +1,58 @@ +provision->fresh(); + + if (! $provision) { + return; + } + + try { + if ($provision->status === ContaboInfrastructureProvision::STATUS_PENDING) { + $provisioner->createContaboInstance($provision); + + return; + } + + if ($provision->status === ContaboInfrastructureProvision::STATUS_WAITING_IP) { + if (! $provisioner->pollInstanceIp($provision)) { + self::dispatch($provision)->delay(now()->addSeconds(30)); + } + + return; + } + + if ($provision->status === ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING) { + self::dispatch($provision)->delay(now()->addMinutes(2)); + } + } catch (\Throwable $e) { + Log::error('Contabo infrastructure provision failed', [ + 'provision_id' => $provision->id, + 'error' => $e->getMessage(), + ]); + $provision->markFailed($e->getMessage()); + throw $e; + } + } +} diff --git a/app/Jobs/ProvisionDomainSlaveZoneJob.php b/app/Jobs/ProvisionDomainSlaveZoneJob.php new file mode 100644 index 0000000..7d9f802 --- /dev/null +++ b/app/Jobs/ProvisionDomainSlaveZoneJob.php @@ -0,0 +1,38 @@ +find($this->domainId); + if (!$domain || !$domain->isActiveForMail()) { + return; + } + + $masters = config('pdns.slave_masters', []); + $client->ensureSlaveZone($domain, $masters); + } +} diff --git a/app/Jobs/ProvisionDomainZoneJob.php b/app/Jobs/ProvisionDomainZoneJob.php new file mode 100644 index 0000000..106bfaf --- /dev/null +++ b/app/Jobs/ProvisionDomainZoneJob.php @@ -0,0 +1,115 @@ +find($this->domainId); + if (! $domain) { + return; + } + + // Ensure DKIM keys exist before provisioning zone + $this->ensureDkimKeys($domain); + + try { + $client->ensureZone($domain); + $this->logCheck($domain, 'success', 'PDNS zone provisioned successfully.'); + ProvisionDomainSlaveZoneJob::dispatch($domain->id); + } catch (RequestException $exception) { + $this->logCheck($domain, 'failed', $exception->getMessage()); + throw $exception; + } + } + + private function ensureDkimKeys(Domain $domain): void + { + $existing = DomainDkimKey::query() + ->where('domain_id', $domain->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + if ($existing) { + return; + } + + $prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill'); + $selector = Str::lower(trim($prefix)) ?: 'ladill'; + $selector .= '1'; + + $config = [ + 'digest_alg' => 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]; + + $res = openssl_pkey_new($config); + openssl_pkey_export($res, $privateKey); + $details = openssl_pkey_get_details($res); + $publicKey = $details['key'] ?? ''; + + $publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/', '', $publicKey); + + $privatePath = null; + if (is_string($privateKey) && trim($privateKey) !== '') { + $safeHost = Str::slug($domain->host, '_'); + $directory = storage_path('app/mail/dkim'); + File::ensureDirectoryExists($directory); + $privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key'; + File::put($privatePath, $privateKey); + } + + DomainDkimKey::query()->create([ + 'domain_id' => $domain->id, + 'selector' => $selector, + 'public_key_txt' => $publicKey, + 'private_key_path' => $privatePath, + 'status' => 'active', + ]); + } + + private function logCheck(Domain $domain, string $result, string $notes): void + { + DomainNsCheck::create([ + 'domain_id' => $domain->id, + 'check_type' => 'pdns_provision', + 'result' => $result, + 'status_before' => (string) $domain->status, + 'status_after' => (string) $domain->status, + 'state_before' => (string) ($domain->onboarding_state ?? ''), + 'state_after' => (string) ($domain->onboarding_state ?? ''), + 'notes' => $notes, + 'checked_at' => now(), + ]); + } +} diff --git a/app/Jobs/ProvisionHostingAccountJob.php b/app/Jobs/ProvisionHostingAccountJob.php new file mode 100644 index 0000000..6c3caa5 --- /dev/null +++ b/app/Jobs/ProvisionHostingAccountJob.php @@ -0,0 +1,154 @@ +find($this->accountId); + + if (! $account) { + Log::warning('ProvisionHostingAccountJob: Account not found', ['account_id' => $this->accountId]); + return; + } + + // Skip if already provisioned or not in pending status + if ($account->status !== 'pending' && $account->provisioned_at !== null) { + Log::info('ProvisionHostingAccountJob: Already provisioned', ['account_id' => $this->accountId, 'status' => $account->status]); + return; + } + + $node = $account->node; + if (! $node) { + Log::error('ProvisionHostingAccountJob: No node assigned', ['account_id' => $this->accountId]); + $account->update(['status' => 'failed', 'notes' => 'No hosting node assigned']); + return; + } + + $product = $account->product; + if (! $product) { + Log::error('ProvisionHostingAccountJob: No product found', ['account_id' => $this->accountId]); + $account->update(['status' => 'failed', 'notes' => 'No hosting product found']); + return; + } + + // Mark as provisioning + $account->update(['status' => 'provisioning']); + + try { + $password = \Illuminate\Support\Str::password(16, true, true, false, false); + $domain = $account->primary_domain ?? $account->username . '.ladill.com'; + + $result = $provider->createAccountOnNode($node, [ + 'username' => $account->username, + 'password' => $password, + 'domain' => $domain, + 'php_version' => '8.4', + 'disk_quota_mb' => max((int) ($product->disk_gb ?? 0), 1) * 1024, + ]); + + $docRoot = $result['document_root'] ?? "/home/{$account->username}/public_html"; + + $account->update([ + 'status' => 'active', + 'home_directory' => $result['home_directory'] ?? "/home/{$account->username}", + 'provisioned_at' => now(), + 'metadata' => array_merge((array) $account->metadata, [ + 'provisioned_password' => $password, // Store temporarily for admin reference + 'provisioned_at' => now()->toISOString(), + ]), + ]); + + // Apply resource limits + $provider->applyAccountResourceProfile($account->fresh(), false); + + // Install WordPress if it's a WordPress hosting product + if ($product->type === 'wordpress') { + $wpResult = $provider->installWordPress($node, $account->username, $domain, [ + 'document_root' => $docRoot, + 'admin_email' => $account->user?->email ?? 'admin@' . $domain, + 'site_title' => $domain, + ]); + + // Store WordPress credentials in account metadata + $account->update([ + 'metadata' => array_merge((array) $account->metadata, [ + 'wp_admin_user' => $wpResult['admin_user'] ?? 'admin', + 'wp_admin_password' => $wpResult['admin_password'] ?? null, + 'wp_db_name' => $wpResult['db_name'] ?? null, + 'wp_db_user' => $wpResult['db_user'] ?? null, + 'wp_db_password' => $wpResult['db_password'] ?? null, + ]), + ]); + + Log::info('ProvisionHostingAccountJob: WordPress installed', [ + 'account_id' => $this->accountId, + 'domain' => $domain, + ]); + } + + // Refresh node capacity + $capacityService->refreshNodeResourceTotals($node); + + Log::info('ProvisionHostingAccountJob: Successfully provisioned', [ + 'account_id' => $this->accountId, + 'username' => $account->username, + 'node_id' => $node->id, + ]); + } catch (\Throwable $exception) { + Log::error('ProvisionHostingAccountJob: Failed to provision', [ + 'account_id' => $this->accountId, + 'username' => $account->username, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + $account->update([ + 'status' => 'failed', + 'notes' => 'Provisioning failed: ' . $exception->getMessage(), + ]); + + // Re-throw to trigger retry + throw $exception; + } + } + + public function failed(\Throwable $exception): void + { + Log::error('ProvisionHostingAccountJob: Job failed after all retries', [ + 'account_id' => $this->accountId, + 'error' => $exception->getMessage(), + ]); + + $account = HostingAccount::find($this->accountId); + if ($account) { + $account->update([ + 'status' => 'failed', + 'notes' => 'Provisioning failed after retries: ' . $exception->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/ProvisionHostingOrderJob.php b/app/Jobs/ProvisionHostingOrderJob.php new file mode 100644 index 0000000..cbe7c22 --- /dev/null +++ b/app/Jobs/ProvisionHostingOrderJob.php @@ -0,0 +1,403 @@ +order->status !== CustomerHostingOrder::STATUS_APPROVED) { + return; + } + + $this->order->markAsProvisioning(); + + $product = $this->order->product; + $queueItem = $this->getOrCreateQueueItem(); + + try { + $queueItem->markAsProcessing(); + + if ($product->isSharedHosting()) { + $this->provisionSharedHosting($capacityService, $resourcePolicyService, $sharedProvider, $queueItem); + } elseif ($product->isVps()) { + $this->provisionVps($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem); + } elseif ($product->isDedicated()) { + $this->provisionDedicated($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem); + } else { + throw new \Exception("Unknown product category: {$product->category}"); + } + + $queueItem->markAsCompleted(); + + } catch (\Throwable $e) { + if ($this->shouldHoldForAdmin($e)) { + $message = $e->getMessage(); + $queueItem->markAsFailed($message); + $this->order->fresh()->holdForAdminProvisioning( + $message, + $e instanceof ContaboApiException ? $e->httpStatus : null, + ); + + return; + } + + $queueItem->markAsFailed($e->getMessage()); + + if ($queueItem->canRetry()) { + $this->release($this->backoff); + } else { + $this->order->markAsFailed($e->getMessage()); + } + + throw $e; + } + } + + private function shouldHoldForAdmin(\Throwable $e): bool + { + if ($e instanceof ContaboApiException) { + return $e->isPaymentRequired(); + } + + $product = $this->order->product; + if ($product?->requiresContabo() !== true) { + return false; + } + + return ContaboApiException::messageIndicatesPaymentRequired($e->getMessage()); + } + + private function provisionSharedHosting( + NodeCapacityService $capacityService, + HostingResourcePolicyService $resourcePolicyService, + SharedNodeProvider $sharedProvider, + ProvisioningQueueItem $queueItem + ): void { + $queueItem->addLog('Finding available shared hosting node...'); + + $product = $this->order->product; + $requestedDiskGb = (int) ($product->disk_gb ?? 0); + $segment = $product->preferredNodeSegment(); + $node = $capacityService->findAvailableNodeForProduct($product); + + if (!$node) { + throw new \Exception('No available shared hosting nodes. Please provision a new node.'); + } + + $queueItem->addLog("Selected node: {$node->name} ({$node->ip_address}) [segment={$segment}, requested_disk={$requestedDiskGb}GB]"); + + // Generate username + $username = $this->generateUsername($this->order->domain_name); + $password = Str::password(16, true, true, false, false); + + $queueItem->addLog("Creating account: {$username}"); + $resourceLimits = $resourcePolicyService->defaultLimitsForProduct($product); + + // Create account on node via SSH + $result = $sharedProvider->createAccountOnNode($node, [ + 'username' => $username, + 'password' => $password, + 'domain' => $this->order->domain_name, + 'email' => $this->order->user->email ?: $this->order->guest_email, + 'disk_quota_mb' => max($requestedDiskGb, 1) * 1024, + 'bandwidth_mb' => (($product->bandwidth_gb ?? 100) > 0 ? $product->bandwidth_gb : 100) * 1024, + ]); + + // Create hosting account record + $account = HostingAccount::create([ + 'user_id' => $this->order->user_id, + 'hosting_node_id' => $node->id, + 'hosting_product_id' => $product->id, + 'username' => $username, + 'primary_domain' => $this->order->domain_name, + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + 'home_directory' => $result['home_directory'] ?? "/home/{$username}", + 'allocated_disk_gb' => $requestedDiskGb, + 'disk_used_bytes' => 0, + 'bandwidth_used_bytes' => 0, + 'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'], + 'memory_limit_mb' => $resourceLimits['memory_limit_mb'], + 'process_limit' => $resourceLimits['process_limit'], + 'io_limit_mb' => $resourceLimits['io_limit_mb'], + 'inode_limit' => $resourceLimits['inode_limit'], + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'uploads_restricted' => false, + 'is_flagged' => false, + 'warning_count' => 0, + 'suspended_at' => null, + 'provisioned_at' => now(), + 'metadata' => [ + 'assigned_duration_months' => $this->order->getCycleLengthInMonths(), + 'initial_password' => $password, + 'provisioned_from_order_id' => $this->order->id, + 'provisioned_on_node' => $node->name, + ], + 'resource_limits' => array_merge([ + 'disk_gb' => $requestedDiskGb, + 'bandwidth_gb' => $product->bandwidth_gb, + 'max_domains' => $product->max_domains, + 'max_databases' => $product->max_databases, + ], $resourceLimits), + ]); + + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain_id' => $this->order->domain_id, + 'domain' => $this->order->domain_name, + 'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"), + 'type' => 'primary', + 'status' => 'active', + 'php_version' => '8.4', + 'ssl_enabled' => false, + ]); + + $sharedProvider->applyAccountResourceProfile($account, false); + + $node = $capacityService->refreshNodeResourceTotals($node); + $capacityService->checkNode($node); + + // Install WordPress if it's a WordPress hosting plan + $wpCredentials = []; + if ($product->type === 'wordpress') { + $queueItem->addLog('Installing WordPress...'); + $docRoot = $result['document_root'] ?? "/home/{$username}/public_html"; + $wpCredentials = $sharedProvider->installWordPress($node, $username, $this->order->domain_name, [ + 'document_root' => $docRoot, + 'admin_email' => $this->order->user->email ?: $this->order->guest_email, + 'site_title' => $this->order->domain_name, + ]); + } + + $queueItem->addLog('Provisioning completed successfully.'); + + // Update order with provisioning details + $orderMeta = array_merge($this->order->meta ?? [], [ + 'provisioned_on_node' => $node->name, + 'node_segment' => $node->segment, + 'requested_disk_gb' => $requestedDiskGb, + 'initial_password' => $password, + ]); + + // Add WordPress credentials if installed + if (!empty($wpCredentials)) { + $orderMeta['wp_admin_user'] = $wpCredentials['admin_user'] ?? 'admin'; + $orderMeta['wp_admin_password'] = $wpCredentials['admin_password'] ?? null; + $orderMeta['wp_admin_email'] = $wpCredentials['admin_email'] ?? null; + $orderMeta['wp_db_name'] = $wpCredentials['db_name'] ?? null; + $orderMeta['wp_db_user'] = $wpCredentials['db_user'] ?? null; + $orderMeta['wp_db_password'] = $wpCredentials['db_password'] ?? null; + } + + $this->order->markAsActive([ + 'hosting_node_id' => $node->id, + 'hosting_account_id' => $account->id, + 'server_username' => $username, + 'server_ip' => $node->ip_address, + 'control_panel_url' => "https://{$this->order->domain_name}/cpanel", + 'expires_at' => $this->order->calculateExpiryDate(), + 'meta' => $orderMeta, + ]); + } + + private function provisionVps(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void + { + $queueItem->addLog('Provisioning VPS via Contabo API...'); + + $product = $this->order->product; + + if (!$product->contabo_product_id) { + throw new \Exception('Product does not have a Contabo product ID configured.'); + } + + $provisioning = $this->serverProvisioningConfig($serverOrders, 'VPS'); + if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) { + $bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh()); + $provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap); + $queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.'); + } + $result = $contaboProvider->createInstance($provisioning); + + $queueItem->addLog("VPS created with instance ID: {$result['instance_id']}"); + foreach ($provisioning['manual_follow_up'] ?? [] as $item) { + $queueItem->addLog("Manual follow-up required for {$item}."); + } + + $this->order->markAsActive([ + 'server_ip' => $result['ip_address'] ?? null, + 'server_username' => (string) ($provisioning['default_user'] ?? 'root'), + 'control_panel_url' => $this->serverControlPanelUrl($provisioning), + 'expires_at' => $this->order->calculateExpiryDate(), + 'meta' => array_merge($this->order->meta ?? [], [ + 'contabo_instance_id' => $result['instance_id'], + 'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []), + 'server_panel' => [ + 'panel' => $provisioning['panel'] ?? null, + 'instance_status' => $result['status'] ?? null, + 'power_status' => 'on', + 'region' => $result['region'] ?? null, + 'datacenter' => $result['datacenter'] ?? null, + 'image' => $result['image'] ?? null, + 'last_synced_at' => now()->toIso8601String(), + ], + ]), + ]); + + if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) { + $serverAgentBootstrap->ensureBootstrap($this->order->fresh()); + } + } + + private function provisionDedicated(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void + { + $queueItem->addLog('Provisioning Dedicated Server via Contabo API...'); + + $product = $this->order->product; + + if (!$product->contabo_product_id) { + throw new \Exception('Product does not have a Contabo product ID configured.'); + } + + $provisioning = $this->serverProvisioningConfig($serverOrders, 'Dedicated'); + if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) { + $bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh()); + $provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap); + $queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.'); + } + $result = $contaboProvider->createInstance($provisioning); + + $queueItem->addLog("Dedicated server created with instance ID: {$result['instance_id']}"); + foreach ($provisioning['manual_follow_up'] ?? [] as $item) { + $queueItem->addLog("Manual follow-up required for {$item}."); + } + + $this->order->markAsActive([ + 'server_ip' => $result['ip_address'] ?? null, + 'server_username' => (string) ($provisioning['default_user'] ?? 'root'), + 'control_panel_url' => $this->serverControlPanelUrl($provisioning), + 'expires_at' => $this->order->calculateExpiryDate(), + 'meta' => array_merge($this->order->meta ?? [], [ + 'contabo_instance_id' => $result['instance_id'], + 'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []), + 'server_panel' => [ + 'panel' => $provisioning['panel'] ?? null, + 'instance_status' => $result['status'] ?? null, + 'power_status' => 'on', + 'region' => $result['region'] ?? null, + 'datacenter' => $result['datacenter'] ?? null, + 'image' => $result['image'] ?? null, + 'last_synced_at' => now()->toIso8601String(), + ], + ]), + ]); + + if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) { + $serverAgentBootstrap->ensureBootstrap($this->order->fresh()); + } + } + + /** + * @return array + */ + private function serverProvisioningConfig(ServerOrderService $serverOrders, string $prefix): array + { + $meta = (array) ($this->order->meta ?? []); + $serverOrder = (array) ($meta['server_order'] ?? []); + $selection = (array) ($serverOrder['selection'] ?? []); + $passwordEncrypted = $serverOrder['server_password_encrypted'] ?? null; + + $config = $serverOrders->provisioningConfig( + $this->order->product, + $selection, + "{$prefix}-{$this->order->id}-{$this->order->domain_name}", + $this->order->billing_cycle + ); + + if (is_string($passwordEncrypted) && $passwordEncrypted !== '') { + $config['root_password'] = decrypt($passwordEncrypted); + } + + return $config; + } + + /** + * @param array $provisioning + */ + private function serverControlPanelUrl(array $provisioning): ?string + { + if (($provisioning['panel'] ?? null) !== 'ladill') { + return null; + } + + return route('hosting.server-panel.show', $this->order); + } + + private function getOrCreateQueueItem(): ProvisioningQueueItem + { + $existing = ProvisioningQueueItem::where('customer_hosting_order_id', $this->order->id)->first(); + + if ($existing) { + return $existing; + } + + $provider = match ($this->order->product->category) { + 'shared' => ProvisioningQueueItem::PROVIDER_SHARED_NODE, + 'vps' => ProvisioningQueueItem::PROVIDER_CONTABO_VPS, + 'dedicated' => ProvisioningQueueItem::PROVIDER_CONTABO_DEDICATED, + default => ProvisioningQueueItem::PROVIDER_SHARED_NODE, + }; + + return ProvisioningQueueItem::create([ + 'customer_hosting_order_id' => $this->order->id, + 'status' => ProvisioningQueueItem::STATUS_QUEUED, + 'provider' => $provider, + ]); + } + + private function generateUsername(string $domain): string + { + // Remove TLD and special characters + $base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0])); + $base = substr($base, 0, 8); + + // Add random suffix to ensure uniqueness + return $base . Str::random(4); + } +} diff --git a/app/Jobs/ProvisionHostingSslJob.php b/app/Jobs/ProvisionHostingSslJob.php new file mode 100644 index 0000000..052d68a --- /dev/null +++ b/app/Jobs/ProvisionHostingSslJob.php @@ -0,0 +1,114 @@ +find($this->siteId); + + if (! $site) { + Log::warning('ProvisionHostingSslJob: Site not found', ['site_id' => $this->siteId]); + return; + } + + // Skip if already has SSL + if ($site->ssl_enabled && $site->ssl_status === 'issued') { + Log::info('ProvisionHostingSslJob: SSL already active', ['site_id' => $this->siteId, 'domain' => $site->domain]); + return; + } + + $account = $site->account; + if (! $account || ! $account->node) { + Log::error('ProvisionHostingSslJob: No account or node', ['site_id' => $this->siteId]); + return; + } + + // Skip if account is not active + if ($account->status !== 'active') { + Log::info('ProvisionHostingSslJob: Account not active, skipping SSL', [ + 'site_id' => $this->siteId, + 'account_status' => $account->status, + ]); + return; + } + + $site->update(['ssl_status' => 'provisioning']); + + try { + $provider->requestLetsEncryptCertificate($site); + + $site->update([ + 'ssl_enabled' => true, + 'ssl_status' => 'issued', + 'ssl_provisioned_at' => now(), + 'ssl_expires_at' => now()->addDays(90), + ]); + + Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [ + 'site_id' => $this->siteId, + 'domain' => $site->domain, + ]); + } catch (\Throwable $exception) { + $message = $exception->getMessage(); + + // Check if it's a DNS propagation issue (should retry) + $isDnsIssue = str_contains($message, 'DNS problem') + || str_contains($message, 'NXDOMAIN') + || str_contains($message, 'no valid A records') + || str_contains($message, 'Timeout'); + + $site->update([ + 'ssl_status' => 'failed', + 'ssl_error' => $message, + ]); + + Log::error('ProvisionHostingSslJob: Failed to provision SSL', [ + 'site_id' => $this->siteId, + 'domain' => $site->domain, + 'error' => $message, + 'is_dns_issue' => $isDnsIssue, + ]); + + // Re-throw to trigger retry (especially for DNS issues) + throw $exception; + } + } + + public function failed(\Throwable $exception): void + { + Log::error('ProvisionHostingSslJob: Job failed after all retries', [ + 'site_id' => $this->siteId, + 'error' => $exception->getMessage(), + ]); + + $site = HostedSite::find($this->siteId); + if ($site) { + $site->update([ + 'ssl_status' => 'failed', + 'ssl_error' => 'SSL provisioning failed after multiple attempts: ' . $exception->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/ProvisionMailboxJob.php b/app/Jobs/ProvisionMailboxJob.php new file mode 100644 index 0000000..2b8f401 --- /dev/null +++ b/app/Jobs/ProvisionMailboxJob.php @@ -0,0 +1,70 @@ +with(['domain', 'emailDomain']) + ->find($this->mailboxId); + + if (! $mailbox || (string) $mailbox->status === 'deleting') { + return; + } + + if (! $provisioner->isConfigured()) { + Log::info('ProvisionMailboxJob: Skipped — mail server DB not configured', [ + 'mailbox_id' => $mailbox->id, + ]); + $mailbox->update(['status' => 'active']); + + return; + } + + $password = $this->encryptedPassword !== null + ? decrypt($this->encryptedPassword) + : null; + + try { + if ($mailbox->email_domain_id !== null) { + $provisioner->provisionStandaloneMailbox($mailbox, $password); + } else { + $provisioner->provisionMailbox($mailbox, $password); + } + + $mailbox->update(['status' => 'active']); + } catch (\Throwable $exception) { + $mailbox->update(['status' => 'failed']); + Log::error('ProvisionMailboxJob: Failed', [ + 'mailbox_id' => $mailbox->id, + 'address' => $mailbox->address, + 'error' => $exception->getMessage(), + ]); + + throw $exception; + } + } +} diff --git a/app/Jobs/SslProvisioningJob.php b/app/Jobs/SslProvisioningJob.php new file mode 100644 index 0000000..8fb58d4 --- /dev/null +++ b/app/Jobs/SslProvisioningJob.php @@ -0,0 +1,106 @@ +find($this->domainId); + if (! $domain) { + return; + } + + if ((string) $domain->status !== 'verified' || (string) $domain->onboarding_state !== Domain::STATE_ACTIVE) { + return; + } + + if ((string) $domain->ssl_status === 'active') { + return; + } + + if (! $provisioner->isConfigured()) { + Log::info('SslProvisioningJob: Skipped — SSL not configured', ['domain_id' => $domain->id]); + + return; + } + + $beforeStatus = (string) $domain->status; + $beforeState = (string) ($domain->onboarding_state ?? ''); + + $domain->update(['ssl_status' => 'provisioning']); + + try { + $certOk = $provisioner->provisionCertificate($domain); + if (! $certOk) { + throw new \RuntimeException('certbot failed for ' . $domain->host); + } + + $nginxOk = $provisioner->generateNginxConfig($domain); + if (! $nginxOk) { + throw new \RuntimeException('Nginx config generation failed for ' . $domain->host); + } + + $reloadOk = $provisioner->reloadNginx(); + if (! $reloadOk) { + throw new \RuntimeException('Nginx reload failed after provisioning ' . $domain->host); + } + + $domain->update([ + 'ssl_status' => 'active', + 'ssl_provisioned_at' => now(), + 'ssl_expires_at' => now()->addDays(90), + ]); + + $this->logCheck($domain, 'success', 'SSL certificate provisioned and Nginx configured', $beforeStatus, $beforeState); + + // Notify user that SSL is active + $user = $domain->website?->user; + if ($user) { + $user->notify(new SslProvisionedNotification($domain->fresh())); + } + } catch (\Throwable $exception) { + $domain->update(['ssl_status' => 'failed']); + $this->logCheck($domain, 'failed', $exception->getMessage(), $beforeStatus, $beforeState); + Log::error('SslProvisioningJob: Failed', ['domain_id' => $domain->id, 'error' => $exception->getMessage()]); + + throw $exception; + } + } + + private function logCheck(Domain $domain, string $result, string $notes, string $statusBefore, string $stateBefore): void + { + DomainNsCheck::create([ + 'domain_id' => $domain->id, + 'check_type' => 'ssl_provision', + 'result' => $result, + 'notes' => $notes, + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) ($domain->onboarding_state ?? ''), + 'checked_at' => now(), + ]); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 0000000..60e7cbf --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,116 @@ + 'array', + 'installed_at' => 'datetime', + 'last_updated_at' => 'datetime', + 'progress' => 'integer', + 'total_steps' => 'integer', + ]; + + public const APP_WORDPRESS = 'wordpress'; + public const APP_JOOMLA = 'joomla'; + public const APP_DRUPAL = 'drupal'; + public const APP_OPENCART = 'opencart'; + public const APP_MAGENTO = 'magento'; + + public function site(): BelongsTo + { + return $this->belongsTo(HostedSite::class, 'hosted_site_id'); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function getAdminPasswordAttribute(): ?string + { + return $this->admin_password_encrypted ? decrypt($this->admin_password_encrypted) : null; + } + + public function getDatabasePasswordAttribute(): ?string + { + return $this->database_password_encrypted ? decrypt($this->database_password_encrypted) : null; + } + + public function getAdminUrlAttribute(): ?string + { + if (!$this->install_url || !$this->admin_path) { + return null; + } + return rtrim($this->install_url, '/') . '/' . ltrim($this->admin_path, '/'); + } + + public function updateProgress(int $step, int $totalSteps, string $message, ?string $currentStep = null): void + { + $progress = $totalSteps > 0 ? (int) round(($step / $totalSteps) * 100) : 0; + + $this->update([ + 'progress' => min($progress, 100), + 'progress_message' => $message, + 'current_step' => $currentStep, + 'total_steps' => $totalSteps, + ]); + } + + public function markFailed(string $errorMessage): void + { + $this->update([ + 'status' => 'failed', + 'error_message' => $errorMessage, + 'progress_message' => 'Installation failed', + ]); + } + + public function markCompleted(): void + { + $this->update([ + 'status' => 'active', + 'progress' => 100, + 'progress_message' => 'Installation complete', + 'current_step' => null, + 'installed_at' => now(), + ]); + } + + public function isInstalling(): bool + { + return $this->status === 'installing'; + } + + public function hasFailed(): bool + { + return $this->status === 'failed'; + } +} diff --git a/app/Models/ContaboInfrastructureProvision.php b/app/Models/ContaboInfrastructureProvision.php new file mode 100644 index 0000000..c01bc75 --- /dev/null +++ b/app/Models/ContaboInfrastructureProvision.php @@ -0,0 +1,113 @@ + 'array', + 'log' => 'array', + 'instance_created_at' => 'datetime', + 'ip_assigned_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + ]; + + protected $hidden = [ + 'ssh_private_key', + 'generated_mail_db_password', + 'completion_token', + ]; + + public function hostingNode(): BelongsTo + { + return $this->belongsTo(HostingNode::class); + } + + public function emailServer(): BelongsTo + { + return $this->belongsTo(EmailServer::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function completionUrl(): string + { + return url('/api/infrastructure/contabo/callback/'.$this->id.'?token='.$this->completion_token); + } + + public function purposeLabel(): string + { + return match ($this->purpose) { + self::PURPOSE_HOSTING_NODE => 'Hosting node', + self::PURPOSE_EMAIL_PRIMARY => 'Mail server (primary)', + self::PURPOSE_EMAIL_EXTENSION => 'Mail server (extension)', + default => $this->purpose, + }; + } + + public function addLog(string $message): void + { + $log = $this->log ?? []; + $log[] = ['at' => now()->toIso8601String(), 'message' => $message]; + $this->update(['log' => $log]); + } + + public function markFailed(string $message): void + { + $this->update([ + 'status' => self::STATUS_FAILED, + 'error_message' => $message, + 'failed_at' => now(), + ]); + $this->addLog('Failed: '.$message); + } +} diff --git a/app/Models/CustomerHostingOrder.php b/app/Models/CustomerHostingOrder.php new file mode 100644 index 0000000..2709d4d --- /dev/null +++ b/app/Models/CustomerHostingOrder.php @@ -0,0 +1,317 @@ + 'decimal:2', + 'approved_at' => 'datetime', + 'provisioned_at' => 'datetime', + 'expires_at' => 'datetime', + 'suspended_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'meta' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class)->withDefault(); + } + + public function product(): BelongsTo + { + return $this->belongsTo(HostingProduct::class, 'hosting_product_id'); + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function hostingNode(): BelongsTo + { + return $this->belongsTo(HostingNode::class); + } + + public function hostingAccount(): BelongsTo + { + return $this->belongsTo(HostingAccount::class); + } + + public function vpsInstance(): BelongsTo + { + return $this->belongsTo(VpsInstance::class); + } + + public function provisioningQueue(): HasOne + { + return $this->hasOne(ProvisioningQueueItem::class); + } + + public function serverAgent(): HasOne + { + return $this->hasOne(ServerAgent::class); + } + + public function serverTasks(): HasMany + { + return $this->hasMany(ServerTask::class); + } + + public function serverSites(): HasMany + { + return $this->hasMany(ServerSite::class); + } + + public function serverDatabases(): HasMany + { + return $this->hasMany(ServerDatabase::class); + } + + public function serverFiles(): HasMany + { + return $this->hasMany(ServerFile::class); + } + + public function scopePendingApproval($query) + { + return $query->where('status', self::STATUS_PENDING_APPROVAL); + } + + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function isPendingApproval(): bool + { + return $this->status === self::STATUS_PENDING_APPROVAL; + } + + public function isApproved(): bool + { + return $this->status === self::STATUS_APPROVED; + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isProvisioning(): bool + { + return $this->status === self::STATUS_PROVISIONING; + } + + public function canBeApproved(): bool + { + return $this->status === self::STATUS_PENDING_APPROVAL; + } + + public function canBeCancelled(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING_PAYMENT, + self::STATUS_PENDING_APPROVAL, + self::STATUS_APPROVED, + self::STATUS_ACTIVE, + self::STATUS_SUSPENDED, + ]); + } + + public function approve(User $admin, ?string $notes = null): bool + { + if (!$this->canBeApproved()) { + return false; + } + + $this->update([ + 'status' => self::STATUS_APPROVED, + 'approved_by' => $admin->id, + 'approved_at' => now(), + 'approval_notes' => $notes, + ]); + + return true; + } + + public function markAsProvisioning(): void + { + $this->update(['status' => self::STATUS_PROVISIONING]); + } + + public function markAsActive(array $provisioningDetails = []): void + { + $this->update(array_merge([ + 'status' => self::STATUS_ACTIVE, + 'provisioned_at' => now(), + ], $provisioningDetails)); + } + + public function markAsFailed(?string $reason = null): void + { + $meta = $this->meta ?? []; + $meta['failure_reason'] = $reason; + $meta['failed_at'] = now()->toIso8601String(); + + $this->update([ + 'status' => self::STATUS_FAILED, + 'meta' => $meta, + ]); + } + + /** + * Return a paid order to the admin queue after upstream provisioning could not complete + * (e.g. Contabo account balance). Customer still sees a generic pending state. + */ + public function holdForAdminProvisioning(string $reason, ?int $httpStatus = null): void + { + $meta = $this->meta ?? []; + $meta['provisioning_hold'] = [ + 'reason' => $reason, + 'http_status' => $httpStatus, + 'at' => now()->toIso8601String(), + ]; + unset($meta['failure_reason'], $meta['failed_at']); + + $this->update([ + 'status' => self::STATUS_PENDING_APPROVAL, + 'meta' => $meta, + ]); + } + + public function suspend(?string $reason = null): void + { + $meta = $this->meta ?? []; + $meta['suspension_reason'] = $reason; + + $this->update([ + 'status' => self::STATUS_SUSPENDED, + 'suspended_at' => now(), + 'meta' => $meta, + ]); + } + + public function cancel(?string $reason = null): void + { + $meta = $this->meta ?? []; + $meta['cancellation_reason'] = $reason; + + $this->update([ + 'status' => self::STATUS_CANCELLED, + 'cancelled_at' => now(), + 'meta' => $meta, + ]); + } + + public function getCycleLengthInMonths(): int + { + return match ($this->billing_cycle) { + self::CYCLE_MONTHLY => 1, + self::CYCLE_QUARTERLY => 3, + self::CYCLE_SEMIANNUAL => 6, + self::CYCLE_YEARLY => 12, + self::CYCLE_BIENNIAL => 24, + default => 12, + }; + } + + public function calculateExpiryDate(): \Carbon\Carbon + { + $startDate = $this->provisioned_at ?? now(); + return $startDate->copy()->addMonths($this->getCycleLengthInMonths()); + } + + public function customerFacingStatusLabel(): string + { + return self::customerFacingStatusLabelFor($this->status); + } + + public static function customerFacingStatusLabelFor(?string $status): string + { + return match ($status) { + self::STATUS_PENDING_PAYMENT => 'Pending Payment', + self::STATUS_PENDING_APPROVAL, + self::STATUS_APPROVED, + self::STATUS_PROVISIONING => 'Pending', + self::STATUS_ACTIVE => 'Active', + self::STATUS_SUSPENDED => 'Suspended', + self::STATUS_CANCELLED => 'Cancelled', + self::STATUS_EXPIRED => 'Expired', + self::STATUS_FAILED => 'Failed', + default => 'Pending', + }; + } + + public function isCustomerPending(): bool + { + return in_array($this->status, [ + self::STATUS_PENDING_APPROVAL, + self::STATUS_APPROVED, + self::STATUS_PROVISIONING, + ], true); + } +} diff --git a/app/Models/Domain.php b/app/Models/Domain.php new file mode 100644 index 0000000..eeef5d7 --- /dev/null +++ b/app/Models/Domain.php @@ -0,0 +1,124 @@ + 'datetime', + 'ns_expected' => 'array', + 'ns_observed' => 'array', + 'ns_checked_at' => 'datetime', + 'connected_at' => 'datetime', + 'mail_ready_at' => 'datetime', + 'active_at' => 'datetime', + 'manual_dns_verified_at' => 'datetime', + 'verification_meta' => 'array', + 'ssl_provisioned_at' => 'datetime', + 'ssl_expires_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public function hostingAccount(): BelongsTo + { + return $this->belongsTo(HostingAccount::class); + } + + public function emailDomain(): BelongsTo + { + return $this->belongsTo(EmailDomain::class); + } + + public function emailServer(): BelongsTo + { + return $this->belongsTo(EmailServer::class); + } + + public function dnsRecords(): HasMany + { + return $this->hasMany(DomainDnsRecord::class); + } + + public function dkimKeys(): HasMany + { + return $this->hasMany(DomainDkimKey::class); + } + + public function nsChecks(): HasMany + { + return $this->hasMany(DomainNsCheck::class); + } + + public function mailboxes(): HasMany + { + return $this->hasMany(Mailbox::class); + } + + public function hostedSites(): HasMany + { + return $this->hasMany(HostedSite::class); + } + + public function isActiveForMail(): bool + { + return (string) $this->status === 'verified' + && (string) $this->onboarding_state === self::STATE_ACTIVE; + } +} diff --git a/app/Models/DomainDkimKey.php b/app/Models/DomainDkimKey.php new file mode 100644 index 0000000..284d6b2 --- /dev/null +++ b/app/Models/DomainDkimKey.php @@ -0,0 +1,30 @@ + 'datetime', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } +} diff --git a/app/Models/DomainDnsRecord.php b/app/Models/DomainDnsRecord.php new file mode 100644 index 0000000..2241bac --- /dev/null +++ b/app/Models/DomainDnsRecord.php @@ -0,0 +1,36 @@ + 'integer', + 'priority' => 'integer', + 'last_checked_at' => 'datetime', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } +} diff --git a/app/Models/DomainNsCheck.php b/app/Models/DomainNsCheck.php new file mode 100644 index 0000000..d846226 --- /dev/null +++ b/app/Models/DomainNsCheck.php @@ -0,0 +1,43 @@ + 'array', + 'observed_nameservers' => 'array', + 'has_authoritative_answer' => 'boolean', + 'manual_records_total' => 'integer', + 'manual_records_verified' => 'integer', + 'checked_at' => 'datetime', + ]; + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } +} diff --git a/app/Models/DomainOrder.php b/app/Models/DomainOrder.php new file mode 100644 index 0000000..1748ffa --- /dev/null +++ b/app/Models/DomainOrder.php @@ -0,0 +1,166 @@ + 'integer', + 'years' => 'integer', + 'contact_info' => 'array', + 'nameservers' => 'array', + 'meta' => 'array', + 'paid_at' => 'datetime', + 'submitted_at' => 'datetime', + 'completed_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function setAuthCode(string $authCode): void + { + $this->auth_code_encrypted = Crypt::encryptString($authCode); + } + + public function getAuthCode(): ?string + { + if (empty($this->auth_code_encrypted)) { + return null; + } + + try { + return Crypt::decryptString($this->auth_code_encrypted); + } catch (\Exception $e) { + return null; + } + } + + public function getTld(): string + { + $parts = explode('.', $this->domain_name, 2); + return $parts[1] ?? ''; + } + + public function formatAmount(): string + { + $amount = $this->amount_minor / 100; + $symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency; + return $symbol . number_format($amount, 2); + } + + public function getOrderTypeLabel(): string + { + return match ($this->order_type) { + self::TYPE_REGISTRATION => 'Registration', + self::TYPE_TRANSFER => 'Transfer', + self::TYPE_RENEWAL => 'Renewal', + default => ucfirst($this->order_type), + }; + } + + public function getStatusLabel(): string + { + return match ($this->status) { + self::STATUS_CART => 'In Cart', + self::STATUS_PENDING_PAYMENT => 'Awaiting Payment', + self::STATUS_PENDING => 'Pending', + self::STATUS_PENDING_CUSTOMER_REGISTRATION => 'Awaiting Registration', + self::STATUS_PROCESSING => 'Processing', + self::STATUS_COMPLETED => 'Completed', + self::STATUS_FAILED => 'Failed', + self::STATUS_CANCELLED => 'Cancelled', + default => ucfirst($this->status), + }; + } + + public function isInCart(): bool + { + return in_array($this->status, [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]); + } + + public function isPaid(): bool + { + return $this->payment_status === self::PAYMENT_PAID; + } + + public function scopeInCart($query) + { + return $query->whereIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]); + } + + public function scopeForUser($query, User $user) + { + return $query->where('user_id', $user->id); + } + + public function scopePendingFulfillment($query) + { + return $query->where('payment_status', self::PAYMENT_PAID) + ->whereIn('fulfillment_status', [ + self::FULFILLMENT_PAYMENT_RECEIVED, + self::FULFILLMENT_SUBMITTING, + self::FULFILLMENT_AWAITING_REGISTRAR, + ]); + } +} diff --git a/app/Models/DomainPricing.php b/app/Models/DomainPricing.php new file mode 100644 index 0000000..8ee86d1 --- /dev/null +++ b/app/Models/DomainPricing.php @@ -0,0 +1,230 @@ + + */ + private static array $columnExists = []; + + protected $fillable = [ + 'tld', + 'register_price', + 'renew_price', + 'transfer_price', + 'currency', + 'is_override', + 'is_featured', + 'is_active', + 'sort_order', + ]; + + protected $casts = [ + 'register_price' => 'integer', + 'renew_price' => 'integer', + 'transfer_price' => 'integer', + 'is_override' => 'boolean', + 'is_featured' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Get active pricing override for a TLD. + */ + public static function forTld(string $tld): ?self + { + $tld = ltrim(strtolower(trim($tld)), '.'); + + return Cache::remember( + "domain_pricing:{$tld}", + now()->addHours(1), + function () use ($tld) { + $query = static::where('tld', $tld); + + if (static::hasColumn('is_active')) { + $query->where('is_active', true); + } + + return $query->first(); + } + ); + } + + /** + * Get all active price overrides. + * + * @return array + */ + public static function getOverrides(): array + { + return Cache::remember( + 'domain_pricing:overrides', + now()->addHours(1), + function () { + $query = static::query(); + + if (static::hasColumn('is_active')) { + $query->where('is_active', true); + } + + // Older installs may have used this table only for manual overrides. + if (static::hasColumn('is_override')) { + $query->where('is_override', true); + } + + return $query->get() + ->keyBy('tld') + ->map(fn ($p) => [ + 'register' => $p->register_price, + 'renew' => $p->renew_price, + 'transfer' => $p->transfer_price, + 'is_override' => static::hasColumn('is_override') ? (bool) $p->is_override : true, + ]) + ->all(); + } + ); + } + + /** + * Get all active TLDs with pricing. + */ + public static function activeTlds(): array + { + return Cache::remember( + 'domain_pricing:active_tlds', + now()->addHours(1), + function () { + $query = static::query(); + + if (static::hasColumn('is_active')) { + $query->where('is_active', true); + } + + if (static::hasColumn('sort_order')) { + $query->orderBy('sort_order'); + } + + return $query->orderBy('tld')->pluck('tld')->all(); + } + ); + } + + /** + * Get featured TLDs for search. + */ + public static function featuredTlds(): array + { + return Cache::remember( + 'domain_pricing:featured_tlds', + now()->addHours(1), + function () { + $query = static::query(); + + if (static::hasColumn('is_active')) { + $query->where('is_active', true); + } + + if (static::hasColumn('is_featured')) { + $query->where('is_featured', true); + } + + if (static::hasColumn('sort_order')) { + $query->orderBy('sort_order'); + } + + $tlds = $query->orderBy('tld')->pluck('tld')->all(); + + return $tlds !== [] ? $tlds : static::activeTlds(); + } + ); + } + + /** + * Get pricing map for multiple TLDs. + * + * @return array + */ + public static function pricingMap(array $tlds = []): array + { + $query = static::query(); + + if (static::hasColumn('is_active')) { + $query->where('is_active', true); + } + + if (! empty($tlds)) { + $query->whereIn('tld', array_map(fn ($t) => ltrim(strtolower($t), '.'), $tlds)); + } + + return $query->get()->keyBy('tld')->map(fn ($p) => [ + 'register' => $p->register_price, + 'renew' => $p->renew_price, + 'transfer' => $p->transfer_price, + 'currency' => $p->currency, + ])->all(); + } + + /** + * Clear pricing cache. + */ + public static function clearCache(): void + { + Cache::forget('domain_pricing:active_tlds'); + Cache::forget('domain_pricing:featured_tlds'); + Cache::forget('domain_pricing:overrides'); + + foreach (static::pluck('tld') as $tld) { + Cache::forget("domain_pricing:{$tld}"); + } + } + + /** + * Format price for display. + */ + public function formatRegisterPrice(): string + { + return $this->formatPrice($this->register_price); + } + + public function formatRenewPrice(): string + { + return $this->formatPrice($this->renew_price); + } + + public function formatTransferPrice(): string + { + return $this->formatPrice($this->transfer_price); + } + + private function formatPrice(int $amountMinor): string + { + $symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency; + return $symbol . ' ' . number_format($amountMinor / 100, 2); + } + + private static function hasColumn(string $column): bool + { + $cacheKey = static::class . ':' . $column; + + if (! array_key_exists($cacheKey, self::$columnExists)) { + self::$columnExists[$cacheKey] = Schema::hasColumn((new static)->getTable(), $column); + } + + return self::$columnExists[$cacheKey]; + } +} diff --git a/app/Models/EmailDomain.php b/app/Models/EmailDomain.php new file mode 100644 index 0000000..2733cdd --- /dev/null +++ b/app/Models/EmailDomain.php @@ -0,0 +1,260 @@ + 'array', + 'ns_observed' => 'array', + 'ns_checked_at' => 'datetime', + 'verified_at' => 'datetime', + 'mail_ready_at' => 'datetime', + 'dns_records' => 'array', + 'dns_verification_status' => 'array', + 'dns_last_checked_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function emailServer(): BelongsTo + { + return $this->belongsTo(EmailServer::class); + } + + public function mailboxes(): HasMany + { + return $this->hasMany(Mailbox::class, 'email_domain_id'); + } + + public function dkimKeys(): HasMany + { + return $this->hasMany(EmailDomainDkimKey::class); + } + + public function activeDkimKey(): HasOne + { + return $this->hasOne(EmailDomainDkimKey::class)->where('status', 'deployed')->latest(); + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isVerifying(): bool + { + return $this->status === self::STATUS_VERIFYING; + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isSuspended(): bool + { + return $this->status === self::STATUS_SUSPENDED; + } + + public function isReadyForMail(): bool + { + // Domain is ready for mail if it's active (verified) + // mail_ready_at is set when infrastructure is provisioned, but we allow + // mailbox creation as soon as domain is verified + return $this->isActive(); + } + + public function usesNameserverVerification(): bool + { + return $this->verification_method === self::METHOD_NAMESERVER; + } + + public function usesDnsRecordVerification(): bool + { + return $this->verification_method === self::METHOD_DNS_RECORD; + } + + public function getMailboxCountAttribute(): int + { + return $this->mailboxes()->count(); + } + + public function getFreeMailboxesLimitAttribute($value): int + { + $base = $value ?? self::FREE_MAILBOXES_LIMIT; + + // Check if user has hosting accounts with higher email quotas + $hostingAllowance = $this->getHostingEmailAllowance(); + + return max($base, $hostingAllowance); + } + + /** + * Get the email allowance from the user's hosting accounts for this domain. + */ + public function getHostingEmailAllowance(): int + { + $hostingAccount = HostingAccount::query() + ->where('user_id', $this->user_id) + ->where('status', HostingAccount::STATUS_ACTIVE) + ->whereHas('sites', fn ($q) => $q->where('domain', $this->domain)) + ->with('product') + ->first(); + + if (! $hostingAccount) { + // Also check primary_domain + $hostingAccount = HostingAccount::query() + ->where('user_id', $this->user_id) + ->where('status', HostingAccount::STATUS_ACTIVE) + ->where('primary_domain', $this->domain) + ->with('product') + ->first(); + } + + if (! $hostingAccount) { + return 0; + } + + return $hostingAccount->freeEmailAllowance() ?? 0; + } + + /** + * Get per-record DNS verification results. + */ + public function getDnsRecordStatuses(): array + { + return $this->dns_verification_status ?? []; + } + + /** + * Whether an authentication record (spf|dkim|dmarc) should display as + * verified. The per-record dns_verification_status map is only populated by + * the DNS-record verification path; nameserver-managed / Ladill-owned domains + * (we publish their DNS) leave it empty even when fully configured. So an + * active domain necessarily has SPF + DKIM in place (required to activate), + * and DMARC is published for nameserver-managed domains. + */ + public function authVerified(string $key): bool + { + if (($this->getDnsRecordStatuses()[$key]['status'] ?? null) === 'verified') { + return true; + } + + return match ($key) { + 'spf', 'dkim' => $this->isActive(), + 'dmarc' => $this->isActive() && $this->usesNameserverVerification(), + default => false, + }; + } + + /** + * Check if all required DNS records are verified. + */ + public function allDnsRecordsVerified(): bool + { + $statuses = $this->getDnsRecordStatuses(); + + if (empty($statuses)) { + return false; + } + + // All required records must be verified + foreach ($statuses as $record) { + if (($record['required'] ?? true) && ($record['status'] ?? 'pending') !== 'verified') { + return false; + } + } + + return true; + } + + /** + * Count verified and total required DNS records. + */ + public function dnsVerificationProgress(): array + { + $statuses = $this->getDnsRecordStatuses(); + $required = collect($statuses)->filter(fn ($r) => $r['required'] ?? true); + + return [ + 'total' => $required->count(), + 'verified' => $required->where('status', 'verified')->count(), + 'pending' => $required->where('status', '!=', 'verified')->count(), + ]; + } + + public function getFreeMailboxesUsedAttribute(): int + { + return $this->mailboxes()->where('is_paid', false)->count(); + } + + public function getPaidMailboxesUsedAttribute(): int + { + return $this->mailboxes()->where('is_paid', true)->count(); + } + + public function getFreeMailboxesRemainingAttribute(): int + { + return max(0, $this->free_mailboxes_limit - $this->free_mailboxes_used); + } + + public function canCreateFreeMailbox(): bool + { + return $this->free_mailboxes_remaining > 0; + } + + public function requiresPaymentForNextMailbox(): bool + { + return !$this->canCreateFreeMailbox(); + } + + public function monthlySubscriptionPrice(): float + { + return self::SUBSCRIPTION_MONTHLY_PRICE_CEDIS; + } +} diff --git a/app/Models/EmailServer.php b/app/Models/EmailServer.php new file mode 100644 index 0000000..8dc6394 --- /dev/null +++ b/app/Models/EmailServer.php @@ -0,0 +1,148 @@ + 'array', + 'last_health_check_at' => 'datetime', + 'last_usage_sync_at' => 'datetime', + 'is_default' => 'boolean', + 'db_port' => 'integer', + 'ssh_port' => 'integer', + ]; + + protected $hidden = [ + 'db_password', + 'ssh_private_key', + ]; + + public function primary(): BelongsTo + { + return $this->belongsTo(self::class, 'primary_email_server_id'); + } + + public function extensions(): HasMany + { + return $this->hasMany(self::class, 'primary_email_server_id'); + } + + public function emailDomains(): HasMany + { + return $this->hasMany(EmailDomain::class); + } + + public function domains(): HasMany + { + return $this->hasMany(Domain::class); + } + + public function capacityAlerts(): HasMany + { + return $this->hasMany(MailServerCapacityAlert::class); + } + + public function isPrimary(): bool + { + return $this->role === self::ROLE_PRIMARY; + } + + public function isExtension(): bool + { + return $this->role === self::ROLE_EXTENSION; + } + + public function isAvailable(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function poolRoot(): self + { + return $this->isExtension() && $this->primary + ? $this->primary + : $this; + } + + public function mailDatabaseServer(): self + { + return $this->poolRoot(); + } + + public function scopePrimaries(Builder $query): Builder + { + return $query->where('role', self::ROLE_PRIMARY); + } + + public function scopeAvailable(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE); + } +} diff --git a/app/Models/EmailSetting.php b/app/Models/EmailSetting.php new file mode 100644 index 0000000..3b601eb --- /dev/null +++ b/app/Models/EmailSetting.php @@ -0,0 +1,13 @@ + 'boolean']; +} diff --git a/app/Models/EmailTeamMember.php b/app/Models/EmailTeamMember.php new file mode 100644 index 0000000..e3850b4 --- /dev/null +++ b/app/Models/EmailTeamMember.php @@ -0,0 +1,48 @@ + 'datetime']; + + public function account(): BelongsTo + { + return $this->belongsTo(User::class, 'account_id'); + } + + public function member(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Link any pending invites for this email to the user (called on SSO login), + * so invited teammates gain access the first time they sign in. + */ + public static function linkPendingInvitesFor(User $user): void + { + static::query() + ->whereNull('user_id') + ->where('status', self::STATUS_INVITED) + ->whereRaw('LOWER(email) = ?', [strtolower($user->email)]) + ->update([ + 'user_id' => $user->id, + 'status' => self::STATUS_ACTIVE, + 'accepted_at' => now(), + 'token' => null, + ]); + } +} diff --git a/app/Models/HostedDatabase.php b/app/Models/HostedDatabase.php new file mode 100644 index 0000000..5320229 --- /dev/null +++ b/app/Models/HostedDatabase.php @@ -0,0 +1,35 @@ + 'integer']; + protected $hidden = ['password_encrypted']; + + public function account(): BelongsTo + { + return $this->belongsTo(HostingAccount::class, 'hosting_account_id'); + } + + public function site(): BelongsTo + { + return $this->belongsTo(HostedSite::class, 'hosted_site_id'); + } + + public function getHostAttribute(): string + { + return 'localhost'; + } +} diff --git a/app/Models/HostedSite.php b/app/Models/HostedSite.php new file mode 100644 index 0000000..e96749c --- /dev/null +++ b/app/Models/HostedSite.php @@ -0,0 +1,65 @@ + 'boolean', + 'ssl_expires_at' => 'datetime', + 'ssl_provisioned_at' => 'datetime', + 'app_config' => 'array', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(HostingAccount::class, 'hosting_account_id'); + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function databases(): HasMany + { + return $this->hasMany(HostedDatabase::class); + } + + public function appInstallation(): HasOne + { + return $this->hasOne(AppInstallation::class)->where('status', 'active')->latestOfMany(); + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } +} diff --git a/app/Models/HostingAccount.php b/app/Models/HostingAccount.php new file mode 100644 index 0000000..aef6767 --- /dev/null +++ b/app/Models/HostingAccount.php @@ -0,0 +1,377 @@ + 'array', + 'resource_limits' => 'array', + 'metadata' => 'array', + 'allocated_disk_gb' => 'integer', + 'inode_count' => 'integer', + 'cpu_usage_percent' => 'decimal:2', + 'memory_used_mb' => 'integer', + 'process_count' => 'integer', + 'io_usage_mb' => 'decimal:2', + 'cpu_limit_percent' => 'integer', + 'memory_limit_mb' => 'integer', + 'process_limit' => 'integer', + 'io_limit_mb' => 'integer', + 'inode_limit' => 'integer', + 'uploads_restricted' => 'boolean', + 'is_flagged' => 'boolean', + 'warning_count' => 'integer', + 'cpu_breach_streak' => 'integer', + 'memory_breach_streak' => 'integer', + 'process_breach_streak' => 'integer', + 'io_breach_streak' => 'integer', + 'inode_breach_streak' => 'integer', + 'last_usage_sync_at' => 'datetime', + 'last_warning_at' => 'datetime', + 'last_resource_breach_at' => 'datetime', + 'throttled_at' => 'datetime', + 'bandwidth_reset_at' => 'datetime', + 'suspended_at' => 'datetime', + 'provisioned_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(HostingProduct::class, 'hosting_product_id'); + } + + public function node(): BelongsTo + { + return $this->belongsTo(HostingNode::class, 'hosting_node_id'); + } + + public function sites(): HasMany + { + return $this->hasMany(HostedSite::class); + } + + /** Primary label for UI lists — prefers linked hosted_sites over primary_domain. */ + public function linkedDomainLabel(): ?string + { + if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) { + $domains = $this->sites->pluck('domain')->filter()->unique()->values(); + + if ($domains->count() === 1) { + return (string) $domains->first(); + } + + $preview = $domains->take(2)->implode(', '); + + return $domains->count() > 2 + ? "{$preview} +".($domains->count() - 2) + : $preview; + } + + return filled($this->primary_domain) ? (string) $this->primary_domain : null; + } + + public function databases(): HasMany + { + return $this->hasMany(HostedDatabase::class); + } + + public function teamMembers(): HasMany + { + return $this->hasMany(HostingAccountMember::class); + } + + public function developers(): BelongsToMany + { + return $this->belongsToMany(User::class, 'hosting_account_members') + ->withPivot(['role', 'invited_by_user_id', 'invited_at']) + ->withTimestamps(); + } + + public function alerts(): HasMany + { + return $this->hasMany(HostingAccountAlert::class); + } + + public function mailboxes(): HasMany + { + return $this->hasMany(Mailbox::class, 'hosting_account_id'); + } + + /** The extracted hosting app does not store mailboxes locally — they live on Ladill Email. */ + public static function tracksLocalMailboxes(): bool + { + static $hasTable = null; + + return $hasTable ??= Schema::hasTable('mailboxes'); + } + + /** @return Collection */ + public function loadedMailboxes(): Collection + { + if (! static::tracksLocalMailboxes()) { + return new Collection; + } + + if (! $this->relationLoaded('mailboxes')) { + $this->load('mailboxes'); + } + + return $this->mailboxes; + } + + public function billingInvoices(): HasMany + { + return $this->hasMany(HostingBillingInvoice::class, 'hosting_account_id'); + } + + public function planChanges(): HasMany + { + return $this->hasMany(HostingPlanChange::class, 'hosting_account_id'); + } + + public function latestOrder(): HasOne + { + return $this->hasOne(CustomerHostingOrder::class, 'hosting_account_id')->latestOfMany(); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } + + public function scopeActive($query) + { + return $query->where('status', 'active'); + } + + public function isThrottled(): bool + { + return $this->resource_status === self::RESOURCE_STATUS_THROTTLED; + } + + public function uploadsAreRestricted(): bool + { + return (bool) $this->uploads_restricted || ( + $this->inode_limit > 0 && $this->inode_count >= $this->inode_limit + ); + } + + public function assignedDurationMonths(): ?int + { + $months = data_get($this->metadata, 'assigned_duration_months'); + + return is_numeric($months) && (int) $months > 0 ? (int) $months : null; + } + + public function canBeRenewed(): bool + { + return $this->assignedDurationMonths() !== null; + } + + public function requestedDiskGb(): int + { + if ($this->allocated_disk_gb > 0) { + return (int) $this->allocated_disk_gb; + } + + return (int) ($this->product?->disk_gb + ?? data_get($this->resource_limits, 'disk_gb') + ?? 0); + } + + public function diskLimitBytes(): int + { + return max($this->requestedDiskGb(), 0) * 1073741824; + } + + public function diskUsagePercent(): float + { + $limit = $this->diskLimitBytes(); + + if ($limit <= 0) { + return 0; + } + + return round(($this->disk_used_bytes / $limit) * 100, 2); + } + + public function inodeUsagePercent(): float + { + if (! $this->inode_limit || $this->inode_limit <= 0) { + return 0; + } + + return round(($this->inode_count / $this->inode_limit) * 100, 2); + } + + public function includedEmailAccounts(): ?int + { + $limit = $this->product?->max_email_accounts; + + if ($limit === null) { + $limit = data_get($this->resource_limits, 'max_email_accounts'); + } + + return $limit === null ? null : (int) $limit; + } + + public function freeEmailAllowance(): ?int + { + $included = $this->includedEmailAccounts(); + + if ($included === null || $included < 0) { + return null; + } + + return max($included, 0); + } + + public function paidMailboxCount(): int + { + if (! static::tracksLocalMailboxes()) { + return 0; + } + + return $this->loadedMailboxes()->where('is_paid', true)->count(); + } + + public function renew(?int $durationMonths = null, array $metadata = []): void + { + $months = $durationMonths ?? $this->assignedDurationMonths(); + + if (! is_int($months) || $months < 1) { + return; + } + + $baseDate = $this->expires_at instanceof CarbonInterface && $this->expires_at->isFuture() + ? $this->expires_at->copy() + : now(); + + $currentMetadata = (array) ($this->metadata ?? []); + + $mergedMetadata = array_merge($currentMetadata, $metadata, [ + 'assigned_duration_months' => $months, + ]); + unset($mergedMetadata['expiry_alerts_sent']); + + $this->update([ + 'expires_at' => $baseDate->addMonthsNoOverflow($months), + 'metadata' => $mergedMetadata, + ]); + } + + public function isExpired(): bool + { + return $this->expires_at instanceof CarbonInterface && $this->expires_at->isPast(); + } + + public function isInGracePeriod(): bool + { + if (! $this->isExpired()) { + return false; + } + + return $this->gracePeriodEndsAt()->isFuture(); + } + + public function gracePeriodEndsAt(): ?CarbonInterface + { + if (! $this->expires_at instanceof CarbonInterface) { + return null; + } + + return $this->expires_at->copy()->addMonths(self::GRACE_PERIOD_MONTHS); + } + + public function isPastGracePeriod(): bool + { + $endsAt = $this->gracePeriodEndsAt(); + + return $endsAt !== null && $endsAt->isPast(); + } +} diff --git a/app/Models/HostingAccountAlert.php b/app/Models/HostingAccountAlert.php new file mode 100644 index 0000000..fcf19d0 --- /dev/null +++ b/app/Models/HostingAccountAlert.php @@ -0,0 +1,43 @@ + 'array', + 'is_resolved' => 'boolean', + 'resolved_at' => 'datetime', + ]; + + public function account(): BelongsTo + { + return $this->belongsTo(HostingAccount::class, 'hosting_account_id'); + } + + public function scopeUnresolved($query) + { + return $query->where('is_resolved', false); + } +} diff --git a/app/Models/HostingAccountMember.php b/app/Models/HostingAccountMember.php new file mode 100644 index 0000000..d39b66e --- /dev/null +++ b/app/Models/HostingAccountMember.php @@ -0,0 +1,72 @@ + 'datetime', + 'ssh_key_installed_at' => 'datetime', + ]; + + public function hostingAccount(): BelongsTo + { + return $this->belongsTo(HostingAccount::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } + + public function sshKeyMarker(): string + { + return 'ladill-team-member-'.$this->id; + } + + public function hasSshAccessKey(): bool + { + return filled($this->ssh_public_key); + } + + public static function normalizeSshPublicKey(string $value): string + { + return trim(preg_replace('/\s+/', ' ', trim($value)) ?? ''); + } + + public static function looksLikeSshPublicKey(?string $value): bool + { + if (! is_string($value)) { + return false; + } + + $normalized = static::normalizeSshPublicKey($value); + + return $normalized !== '' && preg_match(self::SSH_KEY_PATTERN, $normalized) === 1; + } +} diff --git a/app/Models/HostingBillingInvoice.php b/app/Models/HostingBillingInvoice.php new file mode 100644 index 0000000..978fe88 --- /dev/null +++ b/app/Models/HostingBillingInvoice.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'credit_amount' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'paid_at' => 'datetime', + 'line_items' => 'array', + 'metadata' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(HostingAccount::class, 'hosting_account_id'); + } + + public function planChange(): BelongsTo + { + return $this->belongsTo(HostingPlanChange::class, 'hosting_plan_change_id'); + } +} diff --git a/app/Models/HostingNode.php b/app/Models/HostingNode.php new file mode 100644 index 0000000..e74325f --- /dev/null +++ b/app/Models/HostingNode.php @@ -0,0 +1,250 @@ + 'array', + 'installed_software' => 'array', + 'health_status' => 'array', + 'last_health_check_at' => 'datetime', + 'cpu_cores' => 'integer', + 'ram_mb' => 'integer', + 'disk_gb' => 'integer', + 'oversell_ratio' => 'decimal:2', + 'allocated_disk_gb' => 'integer', + 'used_disk_gb' => 'integer', + 'bandwidth_tb' => 'integer', + 'max_accounts' => 'integer', + 'current_accounts' => 'integer', + 'current_load_percent' => 'decimal:2', + ]; + + protected $hidden = [ + 'ssh_private_key', + ]; + + public const ROLE_PRIMARY = 'primary'; + + public const ROLE_EXTENSION = 'extension'; + + public const TYPE_SHARED = 'shared'; + public const TYPE_VPS = 'vps'; + public const TYPE_DEDICATED = 'dedicated'; + + public const SEGMENT_GENERAL = 'general'; + public const SEGMENT_STARTER = 'starter'; + public const SEGMENT_WORDPRESS = 'wordpress'; + public const SEGMENT_HIGH_RESOURCE = 'high_resource'; + + public const PROVIDER_CONTABO = 'contabo'; + public const PROVIDER_LOCAL = 'local'; + public const PROVIDER_MANUAL = 'manual'; + public const PROVIDER_LEGACY_RC = 'legacy_rc'; + + public const STATUS_PROVISIONING = 'provisioning'; + public const STATUS_ACTIVE = 'active'; + public const STATUS_MAINTENANCE = 'maintenance'; + public const STATUS_FULL = 'full'; + public const STATUS_DECOMMISSIONED = 'decommissioned'; + + public function primary(): BelongsTo + { + return $this->belongsTo(self::class, 'primary_hosting_node_id'); + } + + public function extensions(): HasMany + { + return $this->hasMany(self::class, 'primary_hosting_node_id'); + } + + public function accounts(): HasMany + { + return $this->hasMany(HostingAccount::class); + } + + public function isPrimary(): bool + { + return ($this->role ?? self::ROLE_PRIMARY) === self::ROLE_PRIMARY; + } + + public function isExtension(): bool + { + return $this->role === self::ROLE_EXTENSION; + } + + public function poolRoot(): self + { + return $this->isExtension() && $this->primary + ? $this->primary + : $this; + } + + public function isAvailable(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) { + return false; + } + + return true; + } + + public function supportsSegment(?string $segment): bool + { + if ($segment === null || $segment === '') { + return true; + } + + return in_array($this->segment, [$segment, self::SEGMENT_GENERAL], true); + } + + public function logicalCapacityGb(): float + { + if (! $this->disk_gb || $this->disk_gb <= 0) { + return 0; + } + + $ratio = (float) ($this->oversell_ratio ?: 1); + + return round($this->disk_gb * max($ratio, 1), 2); + } + + public function logicalCapacityPercent(): float + { + $logicalCapacity = $this->logicalCapacityGb(); + + if ($logicalCapacity <= 0) { + return 0; + } + + return round(($this->allocated_disk_gb / $logicalCapacity) * 100, 2); + } + + public function physicalDiskPercent(): float + { + if (! $this->disk_gb || $this->disk_gb <= 0) { + return 0; + } + + return round(($this->used_disk_gb / $this->disk_gb) * 100, 2); + } + + public function accountCapacityPercent(): float + { + if (! $this->max_accounts || $this->max_accounts <= 0) { + return 0; + } + + return round(($this->current_accounts / $this->max_accounts) * 100, 2); + } + + public function hasLogicalCapacityFor(int $requestedDiskGb = 0): bool + { + if ($requestedDiskGb <= 0) { + return true; + } + + if (! $this->disk_gb || $this->disk_gb <= 0) { + return false; + } + + return ($this->used_disk_gb + $requestedDiskGb) <= $this->disk_gb; + } + + public function availableLogicalDiskGb(): float + { + if (! $this->disk_gb || $this->disk_gb <= 0) { + return 0; + } + + return max($this->disk_gb - $this->used_disk_gb, 0); + } + + public function canProvision(int $requestedDiskGb = 0, ?string $segment = null): bool + { + if (! $this->isAvailable()) { + return false; + } + + if (! $this->supportsSegment($segment)) { + return false; + } + + return $this->hasLogicalCapacityFor($requestedDiskGb); + } + + public function incrementAccountCount(): void + { + $this->increment('current_accounts'); + + if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) { + $this->update(['status' => self::STATUS_FULL]); + } + } + + public function decrementAccountCount(): void + { + $this->decrement('current_accounts'); + + if ($this->status === self::STATUS_FULL && $this->current_accounts < $this->max_accounts) { + $this->update(['status' => self::STATUS_ACTIVE]); + } + } + + public function scopeAvailable($query) + { + return $query->where('status', self::STATUS_ACTIVE) + ->whereRaw('current_accounts < COALESCE(max_accounts, 999999)'); + } + + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } +} diff --git a/app/Models/HostingOrder.php b/app/Models/HostingOrder.php new file mode 100644 index 0000000..60f863f --- /dev/null +++ b/app/Models/HostingOrder.php @@ -0,0 +1,436 @@ + 'datetime', + 'provisioned_at' => 'datetime', + 'meta' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED + || ($this->expires_at && $this->expires_at->isPast()); + } + + public function getDomainNameAttribute($value): string + { + return $value ?: $this->metaValue([ + 'domain_name', + 'domainname', + 'domain-name', + 'domain', + 'hostname', + 'description', + ], ''); + } + + public function getPlanNameAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'plan_name', + 'planname', + 'productcategory', + 'productkey', + 'description', + ]); + + return $resolved !== '' ? $resolved : null; + } + + public function getPlanIdAttribute(): ?string + { + $resolved = $this->metaValue([ + 'plan_id', + 'planid', + 'plan-id', + 'productkey', + 'plan.name', + 'entitytype.entitytypekey', + ]); + + return $resolved !== null && $resolved !== '' ? (string) $resolved : null; + } + + public function getRcOrderIdAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'rc_order_id', + 'order_id', + 'orderid', + 'order-id', + 'entityid', + 'entity-id', + ]); + + return $resolved !== '' ? (string) $resolved : null; + } + + public function getRcCustomerIdAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'rc_customer_id', + 'customer_id', + 'customerid', + 'customer-id', + ]); + + return $resolved !== '' ? (string) $resolved : null; + } + + public function getIpAddressAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'ip_address', + 'ipaddress', + 'ip', + ]); + + return $resolved !== '' ? $resolved : null; + } + + public function getCpanelUrlAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'cpanel_url', + 'cpanelurl', + 'controlpanelurl', + 'access_url', + 'url', + ]); + + if ($this->looksLikeTemporaryHostingUrl($resolved)) { + $resolved = null; + } + + if (! $resolved && $preferredDomain = $this->preferredCpanelDomain()) { + return 'https://'.$preferredDomain.'/cpanel'; + } + + if (! $resolved && $this->ip_address) { + return 'https://'.$this->ip_address.':2083'; + } + + return $resolved !== '' ? $resolved : null; + } + + public function getCpanelUsernameAttribute($value): ?string + { + $resolved = $value ?: $this->metaValue([ + 'cpanel_username', + 'cpanelusername', + 'access_username', + 'username', + 'siteadminusername', + ]); + + return $resolved !== '' ? $resolved : null; + } + + /** + * @return array + */ + public function getNameserversAttribute($value): array + { + $resolved = []; + + if (is_array($value)) { + $resolved = $value; + } else { + $candidate = $this->metaValue([ + 'nameservers', + 'nameserver', + 'name_servers', + 'ns', + 'dns.nameservers', + 'server.nameservers', + ]); + + if (is_array($candidate)) { + $resolved = $candidate; + } else { + $resolved = array_filter([ + $this->metaValue(['ns1', 'nameserver1', 'name_server_1', 'server.ns1']), + $this->metaValue(['ns2', 'nameserver2', 'name_server_2', 'server.ns2']), + $this->metaValue(['ns3', 'nameserver3', 'name_server_3', 'server.ns3']), + $this->metaValue(['ns4', 'nameserver4', 'name_server_4', 'server.ns4']), + ]); + } + } + + return collect($resolved) + ->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B."))) + ->filter() + ->unique() + ->values() + ->all(); + } + + public function getWebsiteUrlAttribute(): ?string + { + $domain = strtolower(trim((string) $this->domain_name)); + + if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) { + return null; + } + + return 'https://'.$domain; + } + + public function getDirectUrlAttribute(): ?string + { + $resolved = $this->metaValue([ + 'tempurl', + 'temp_url', + 'temporary_url', + 'server.tempurl', + 'server.temporary_url', + ]); + + return $resolved !== '' ? (string) $resolved : null; + } + + public function getDiskSpaceMbAttribute($value): ?int + { + $resolved = $value ?? $this->metaValue([ + 'disk_space_mb', + 'diskspace', + 'disk_space', + ]); + + return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null; + } + + public function getBandwidthMbAttribute($value): ?int + { + $resolved = $value ?? $this->metaValue([ + 'bandwidth_mb', + 'bandwidth', + ]); + + return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null; + } + + public function getStatusAttribute($value): string + { + $candidate = $value; + + if ($candidate === null || $candidate === '' || $candidate === self::STATUS_PENDING) { + $candidate = $this->metaValue([ + 'status', + 'currentstatus', + 'orderstatus', + ], $candidate ?: self::STATUS_PENDING); + } + + return $this->normalizeStatus((string) $candidate); + } + + public function getProvisionedAtAttribute($value): ?Carbon + { + if ($value instanceof Carbon) { + return $value; + } + + if ($value) { + return Carbon::parse($value); + } + + $timestamp = $this->metaValue([ + 'provisioned_at', + 'creation_time', + 'creationtime', + 'creation-date', + ]); + + if ($timestamp === null || $timestamp === '') { + return null; + } + + try { + return is_numeric($timestamp) + ? Carbon::createFromTimestamp((int) $timestamp) + : Carbon::parse((string) $timestamp); + } catch (\Throwable) { + return null; + } + } + + public function getExpiresAtAttribute($value): ?Carbon + { + if ($value instanceof Carbon) { + return $value; + } + + if ($value) { + return Carbon::parse($value); + } + + $timestamp = $this->metaValue([ + 'expires_at', + 'end_time', + 'endtime', + 'expirytime', + 'expiry-date', + ]); + + if ($timestamp === null || $timestamp === '') { + return null; + } + + try { + return is_numeric($timestamp) + ? Carbon::createFromTimestamp((int) $timestamp) + : Carbon::parse((string) $timestamp); + } catch (\Throwable) { + return null; + } + } + + private function metaValue(array $keys, mixed $default = null): mixed + { + $meta = $this->meta ?? []; + $sources = []; + + if (is_array($meta)) { + $sources[] = $meta; + + if (is_array($meta['raw'] ?? null)) { + $sources[] = $meta['raw']; + } + } + + foreach ($sources as $source) { + foreach ($keys as $key) { + $nested = Arr::get($source, $key); + if ($nested !== null && $nested !== '') { + return $nested; + } + + if (array_key_exists($key, $source) && $source[$key] !== null && $source[$key] !== '') { + return $source[$key]; + } + } + } + + return $default; + } + + private function normalizeStatus(string $status): string + { + return match (strtolower(trim($status))) { + 'active', 'active (paid)' => self::STATUS_ACTIVE, + 'suspended', 'inactive' => self::STATUS_SUSPENDED, + 'deleted', 'cancelled' => self::STATUS_CANCELLED, + 'expired' => self::STATUS_EXPIRED, + 'failed' => self::STATUS_FAILED, + default => self::STATUS_PENDING, + }; + } + + private function looksLikeTemporaryHostingUrl(?string $value): bool + { + $normalized = strtolower(trim((string) $value)); + + if ($normalized === '') { + return false; + } + + return str_contains($normalized, 'tempwebhost.net') + || str_contains($normalized, '/~'); + } + + private function preferredCpanelDomain(): ?string + { + $domain = strtolower(trim((string) $this->domain_name)); + + if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) { + return null; + } + + if ($this->relationLoaded('domain') && $this->domain) { + return $this->domainIsActive($this->domain) ? $domain : null; + } + + if ($this->domain_id) { + $linkedDomain = $this->domain()->first(); + + if ($linkedDomain) { + return $this->domainIsActive($linkedDomain) ? $domain : null; + } + } + + $matchingDomain = Domain::query() + ->where('host', $domain) + ->latest('id') + ->first(); + + if ($matchingDomain) { + return $this->domainIsActive($matchingDomain) ? $domain : null; + } + + return $domain; + } + + private function domainIsActive(Domain $domain): bool + { + return (string) $domain->status === 'verified' + && (string) $domain->onboarding_state === Domain::STATE_ACTIVE; + } +} diff --git a/app/Models/HostingPlan.php b/app/Models/HostingPlan.php new file mode 100644 index 0000000..c76d78d --- /dev/null +++ b/app/Models/HostingPlan.php @@ -0,0 +1,123 @@ + 'decimal:2', + 'price_yearly' => 'decimal:2', + 'disk_gb' => 'integer', + 'bandwidth_gb' => 'integer', + 'cpu_cores' => 'integer', + 'ram_mb' => 'integer', + 'max_domains' => 'integer', + 'max_databases' => 'integer', + 'max_email_accounts' => 'integer', + 'max_ftp_accounts' => 'integer', + 'ssl_included' => 'boolean', + 'backups_included' => 'boolean', + 'backup_retention_days' => 'integer', + 'features' => 'array', + 'php_versions' => 'array', + 'is_active' => 'boolean', + 'is_featured' => 'boolean', + 'sort_order' => 'integer', + ]; + + public const TYPE_SHARED = 'shared'; + public const TYPE_WORDPRESS = 'wordpress'; + public const TYPE_VPS = 'vps'; + public const TYPE_DEDICATED = 'dedicated'; + public const TYPE_EMAIL = 'email'; + + public function accounts(): HasMany + { + return $this->hasMany(HostingAccount::class); + } + + public function vpsInstances(): HasMany + { + return $this->hasMany(VpsInstance::class); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + public function scopeFeatured($query) + { + return $query->where('is_featured', true); + } + + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('price_monthly'); + } + + public function getYearlyDiscount(): ?float + { + if (!$this->price_yearly || !$this->price_monthly) { + return null; + } + + $monthlyTotal = $this->price_monthly * 12; + return round((($monthlyTotal - $this->price_yearly) / $monthlyTotal) * 100, 1); + } + + public function formatDiskSize(): string + { + if ($this->disk_gb >= 1000) { + return round($this->disk_gb / 1000, 1) . ' TB'; + } + return $this->disk_gb . ' GB'; + } + + public function formatRam(): string + { + if ($this->ram_mb >= 1024) { + return round($this->ram_mb / 1024, 1) . ' GB'; + } + return $this->ram_mb . ' MB'; + } +} diff --git a/app/Models/HostingPlanChange.php b/app/Models/HostingPlanChange.php new file mode 100644 index 0000000..a406ec9 --- /dev/null +++ b/app/Models/HostingPlanChange.php @@ -0,0 +1,75 @@ + 'integer', + 'remaining_days' => 'integer', + 'proration_ratio' => 'decimal:4', + 'old_cycle_price' => 'decimal:2', + 'new_cycle_price' => 'decimal:2', + 'charge_amount' => 'decimal:2', + 'credit_amount' => 'decimal:2', + 'net_amount' => 'decimal:2', + 'applied_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function account(): BelongsTo + { + return $this->belongsTo(HostingAccount::class, 'hosting_account_id'); + } + + public function fromProduct(): BelongsTo + { + return $this->belongsTo(HostingProduct::class, 'from_hosting_product_id'); + } + + public function toProduct(): BelongsTo + { + return $this->belongsTo(HostingProduct::class, 'to_hosting_product_id'); + } + + public function invoice(): HasOne + { + return $this->hasOne(HostingBillingInvoice::class); + } +} diff --git a/app/Models/HostingProduct.php b/app/Models/HostingProduct.php new file mode 100644 index 0000000..3db377e --- /dev/null +++ b/app/Models/HostingProduct.php @@ -0,0 +1,308 @@ + 'GHS', + ]; + + protected $casts = [ + 'price_monthly' => 'decimal:2', + 'price_quarterly' => 'decimal:2', + 'price_yearly' => 'decimal:2', + 'price_biennial' => 'decimal:2', + 'setup_fee' => 'decimal:2', + 'disk_gb' => 'integer', + 'bandwidth_gb' => 'integer', + 'cpu_cores' => 'integer', + 'ram_mb' => 'integer', + 'max_domains' => 'integer', + 'max_databases' => 'integer', + 'max_email_accounts' => 'integer', + 'max_ftp_accounts' => 'integer', + 'ssl_included' => 'boolean', + 'backups_included' => 'boolean', + 'backup_retention_days' => 'integer', + 'features' => 'array', + 'php_versions' => 'array', + 'is_active' => 'boolean', + 'is_featured' => 'boolean', + 'is_visible' => 'boolean', + 'sort_order' => 'integer', + ]; + + public function orders(): HasMany + { + return $this->hasMany(CustomerHostingOrder::class); + } + + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeVisible($query) + { + return $query->where('is_visible', true); + } + + public function scopeOfCategory($query, string $category) + { + return $query->where('category', $category); + } + + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + public function scopeSharedHosting($query) + { + return $query->where('category', self::CATEGORY_SHARED); + } + + public function scopeServers($query) + { + return $query->whereIn('category', [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]); + } + + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('price_monthly'); + } + + public function isSharedHosting(): bool + { + return $this->category === self::CATEGORY_SHARED; + } + + public function isVps(): bool + { + return $this->category === self::CATEGORY_VPS; + } + + public function isDedicated(): bool + { + return $this->category === self::CATEGORY_DEDICATED; + } + + public function requiresContabo(): bool + { + return in_array($this->category, [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]); + } + + public function isActive(): bool + { + return (bool) $this->is_active; + } + + public function isVisible(): bool + { + return (bool) $this->is_visible; + } + + public function getPriceForCycle(string $cycle): ?float + { + // Use dynamic pricing for VPS/Dedicated + if ($this->requiresContabo()) { + // Contabo VPS does not offer quarterly billing + if ($this->isVps() && $cycle === 'quarterly') { + return null; + } + $pricing = $this->getDynamicPricing(); + return match ($cycle) { + 'monthly' => $pricing['monthly'], + 'quarterly' => $pricing['quarterly'], + 'semiannual' => $pricing['semiannual'], + 'yearly' => $pricing['yearly'], + default => null, + }; + } + + return match ($cycle) { + 'monthly' => (float) $this->price_monthly, + 'quarterly' => (float) $this->price_quarterly, + 'semiannual' => null, + 'yearly' => (float) $this->price_yearly, + 'biennial' => (float) $this->price_biennial, + default => null, + }; + } + + public function getDynamicPricing(): array + { + $pricingService = app(HostingPricingService::class); + return $pricingService->getDynamicPriceForProduct($this); + } + + public function getPricingBreakdown(): array + { + $pricingService = app(HostingPricingService::class); + return $pricingService->getPricingBreakdown($this); + } + + public function getDisplayPriceMonthlyAttribute(): float + { + if ($this->requiresContabo()) { + return $this->getDynamicPricing()['monthly']; + } + return (float) $this->price_monthly; + } + + public function getDisplayPriceYearlyAttribute(): float + { + if ($this->requiresContabo()) { + return $this->getDynamicPricing()['yearly']; + } + return (float) $this->price_yearly; + } + + public function getDisplayCurrencyAttribute(): string + { + return 'GHS'; + } + + public function getYearlyDiscount(): ?float + { + $monthlyPrice = $this->display_price_monthly; + $yearlyPrice = $this->display_price_yearly; + + if (!$yearlyPrice || !$monthlyPrice) { + return null; + } + + $monthlyTotal = $monthlyPrice * 12; + return round((($monthlyTotal - $yearlyPrice) / $monthlyTotal) * 100, 1); + } + + public function catalogSummary(): ?string + { + $parts = []; + + if ($this->disk_gb !== null) { + $parts[] = $this->formatDiskSize(); + } + + $domainLimit = $this->formatDomainLimit(); + if ($domainLimit !== null) { + $parts[] = $domainLimit; + } + + return $parts !== [] ? implode(' · ', $parts) : null; + } + + public function formatDiskSize(): string + { + if (!$this->disk_gb) { + return 'Unlimited'; + } + if ($this->disk_gb >= 1000) { + return round($this->disk_gb / 1000, 1) . ' TB'; + } + return $this->disk_gb . ' GB'; + } + + public function formatRam(): string + { + if (!$this->ram_mb) { + return '-'; + } + if ($this->ram_mb >= 1024) { + return round($this->ram_mb / 1024, 1) . ' GB'; + } + return $this->ram_mb . ' MB'; + } + + public function formatBandwidth(): string + { + if (!$this->bandwidth_gb) { + return 'Unlimited'; + } + if ($this->bandwidth_gb >= 1000) { + return round($this->bandwidth_gb / 1000, 1) . ' TB'; + } + return $this->bandwidth_gb . ' GB'; + } + + public function formatDomainLimit(): ?string + { + if ($this->max_domains === null) { + return null; + } + + if ($this->max_domains === -1) { + return 'Unlimited sites'; + } + + return $this->max_domains.' site'.($this->max_domains === 1 ? '' : 's'); + } + + public function preferredNodeSegment(): ?string + { + if (! $this->isSharedHosting()) { + return null; + } + + return match ($this->type) { + self::TYPE_WORDPRESS => HostingNode::SEGMENT_WORDPRESS, + self::TYPE_MULTI_DOMAIN => HostingNode::SEGMENT_HIGH_RESOURCE, + self::TYPE_SINGLE_DOMAIN => HostingNode::SEGMENT_STARTER, + default => HostingNode::SEGMENT_GENERAL, + }; + } +} diff --git a/app/Models/HostingSetting.php b/app/Models/HostingSetting.php new file mode 100644 index 0000000..6c5ca74 --- /dev/null +++ b/app/Models/HostingSetting.php @@ -0,0 +1,13 @@ + 'boolean']; +} diff --git a/app/Models/HostingTeamMember.php b/app/Models/HostingTeamMember.php new file mode 100644 index 0000000..41100e8 --- /dev/null +++ b/app/Models/HostingTeamMember.php @@ -0,0 +1,48 @@ + 'datetime']; + + public function account(): BelongsTo + { + return $this->belongsTo(User::class, 'account_id'); + } + + public function member(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Link any pending invites for this email to the user (called on SSO login), + * so invited teammates gain access the first time they sign in. + */ + public static function linkPendingInvitesFor(User $user): void + { + static::query() + ->whereNull('user_id') + ->where('status', self::STATUS_INVITED) + ->whereRaw('LOWER(email) = ?', [strtolower($user->email)]) + ->update([ + 'user_id' => $user->id, + 'status' => self::STATUS_ACTIVE, + 'accepted_at' => now(), + 'token' => null, + ]); + } +} diff --git a/app/Models/Mailbox.php b/app/Models/Mailbox.php new file mode 100644 index 0000000..d9f11eb --- /dev/null +++ b/app/Models/Mailbox.php @@ -0,0 +1,151 @@ + 'boolean', + 'outbound_enabled' => 'boolean', + 'is_paid' => 'boolean', + 'paid_at' => 'datetime', + 'monthly_price' => 'decimal:2', + 'subscription_started_at' => 'datetime', + 'subscription_expires_at' => 'datetime', + 'smtp_password' => 'encrypted', + 'quota_mb' => 'integer', + 'disk_used_bytes' => 'integer', + 'last_usage_sync_at' => 'datetime', + 'credentials_issued_at' => 'datetime', + 'metadata' => 'array', + ]; + + protected $hidden = [ + 'password_hash', + 'webmail_secret', + 'incoming_token', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function website(): BelongsTo + { + return $this->belongsTo(Website::class); + } + + public function domain(): BelongsTo + { + return $this->belongsTo(Domain::class); + } + + public function hostingAccount(): BelongsTo + { + return $this->belongsTo(HostingAccount::class); + } + + public function emailDomain(): BelongsTo + { + return $this->belongsTo(EmailDomain::class); + } + + public function threads(): HasMany + { + return $this->hasMany(MailThread::class); + } + + public function messages(): HasMany + { + return $this->hasMany(MailMessage::class); + } + + public function isStandalone(): bool + { + return $this->website_id === null && $this->email_domain_id !== null; + } + + public function isWebsiteLinked(): bool + { + return $this->website_id !== null; + } + + public function getDomainHostAttribute(): ?string + { + if ($this->emailDomain) { + return $this->emailDomain->domain; + } + + if ($this->domain) { + return $this->domain->host; + } + + $parts = explode('@', $this->address ?? '', 2); + return $parts[1] ?? null; + } + + public function getOwnerIdAttribute(): ?int + { + if ($this->user_id) { + return $this->user_id; + } + + if ($this->website) { + return $this->website->owner_user_id; + } + + return null; + } +} diff --git a/app/Models/NodeCapacityAlert.php b/app/Models/NodeCapacityAlert.php new file mode 100644 index 0000000..1951236 --- /dev/null +++ b/app/Models/NodeCapacityAlert.php @@ -0,0 +1,75 @@ + 'integer', + 'max_accounts' => 'integer', + 'capacity_percent' => 'decimal:2', + 'resource_usage' => 'array', + 'is_resolved' => 'boolean', + 'resolved_at' => 'datetime', + ]; + + public function hostingNode(): BelongsTo + { + return $this->belongsTo(HostingNode::class); + } + + public function resolvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + public function scopeUnresolved($query) + { + return $query->where('is_resolved', false); + } + + public function scopeCritical($query) + { + return $query->whereIn('alert_type', [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]); + } + + public function resolve(User $admin, ?string $notes = null): void + { + $this->update([ + 'is_resolved' => true, + 'resolved_by' => $admin->id, + 'resolved_at' => now(), + 'resolution_notes' => $notes, + ]); + } + + public function isCritical(): bool + { + return in_array($this->alert_type, [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]); + } +} diff --git a/app/Models/PlatformSetting.php b/app/Models/PlatformSetting.php new file mode 100644 index 0000000..41cfbc4 --- /dev/null +++ b/app/Models/PlatformSetting.php @@ -0,0 +1,28 @@ + 'array', + 'is_secret' => 'boolean', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/PromoBanner.php b/app/Models/PromoBanner.php new file mode 100644 index 0000000..970a21a --- /dev/null +++ b/app/Models/PromoBanner.php @@ -0,0 +1,113 @@ + 'array', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'is_active' => 'boolean', + 'show_topbar' => 'boolean', + 'show_on_homepage' => 'boolean', + 'discount_percent' => 'integer', + 'sort_order' => 'integer', + ]; + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true) + ->where(function ($q) { + $q->whereNull('starts_at') + ->orWhere('starts_at', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('ends_at') + ->orWhere('ends_at', '>=', now()); + }); + } + + public function scopeForTopbar(Builder $query): Builder + { + return $query->active()->where('show_topbar', true); + } + + public static function getActiveTopbar(): ?self + { + return static::forTopbar()->latest()->first(); + } + + public function scopeForHomepage(Builder $query): Builder + { + return $query->active()->where('show_on_homepage', true)->orderBy('sort_order'); + } + + public static function getHomepagePromos() + { + return static::forHomepage()->get(); + } + + public static function getProductTypes(): array + { + return [ + self::TYPE_DOMAINS => 'Domains', + self::TYPE_HOSTING => 'Hosting', + self::TYPE_VPS => 'VPS', + self::TYPE_DEDICATED => 'Dedicated Servers', + self::TYPE_EMAIL => 'Email', + self::TYPE_GENERAL => 'General', + ]; + } + + public function getFeaturedImageUrlAttribute(): ?string + { + if (!$this->featured_image) { + return null; + } + + if (str_starts_with($this->featured_image, 'http')) { + return $this->featured_image; + } + + return asset('storage/' . $this->featured_image); + } +} diff --git a/app/Models/ProvisioningJob.php b/app/Models/ProvisioningJob.php new file mode 100644 index 0000000..ffdbd01 --- /dev/null +++ b/app/Models/ProvisioningJob.php @@ -0,0 +1,94 @@ + 'array', + 'result' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'next_retry_at' => 'datetime', + ]; + + public const STATUS_PENDING = 'pending'; + public const STATUS_RUNNING = 'running'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_FAILED = 'failed'; + public const STATUS_CANCELLED = 'cancelled'; + + protected static function boot() + { + parent::boot(); + static::creating(function ($model) { + $model->uuid = $model->uuid ?? Str::uuid()->toString(); + }); + } + + public function provisionable(): MorphTo + { + return $this->morphTo(); + } + + public function markRunning(): void + { + $this->update([ + 'status' => self::STATUS_RUNNING, + 'started_at' => now(), + 'attempts' => $this->attempts + 1, + ]); + } + + public function markCompleted(array $result = []): void + { + $this->update([ + 'status' => self::STATUS_COMPLETED, + 'completed_at' => now(), + 'result' => $result, + ]); + } + + public function markFailed(string $error, bool $canRetry = true): void + { + $status = ($canRetry && $this->attempts < $this->max_attempts) + ? self::STATUS_PENDING + : self::STATUS_FAILED; + + $this->update([ + 'status' => $status, + 'error_message' => $error, + 'next_retry_at' => $status === self::STATUS_PENDING ? now()->addMinutes(5) : null, + ]); + } + + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING) + ->where(function ($q) { + $q->whereNull('next_retry_at') + ->orWhere('next_retry_at', '<=', now()); + }); + } +} diff --git a/app/Models/ProvisioningQueueItem.php b/app/Models/ProvisioningQueueItem.php new file mode 100644 index 0000000..8755e88 --- /dev/null +++ b/app/Models/ProvisioningQueueItem.php @@ -0,0 +1,114 @@ + 'integer', + 'max_attempts' => 'integer', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'provisioning_log' => 'array', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function scopeQueued($query) + { + return $query->where('status', self::STATUS_QUEUED); + } + + public function scopeProcessing($query) + { + return $query->where('status', self::STATUS_PROCESSING); + } + + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + public function canRetry(): bool + { + return $this->status === self::STATUS_FAILED && $this->attempts < $this->max_attempts; + } + + public function markAsProcessing(): void + { + $this->update([ + 'status' => self::STATUS_PROCESSING, + 'started_at' => now(), + 'attempts' => $this->attempts + 1, + ]); + } + + public function markAsCompleted(): void + { + $this->update([ + 'status' => self::STATUS_COMPLETED, + 'completed_at' => now(), + ]); + } + + public function markAsFailed(string $error): void + { + $log = $this->provisioning_log ?? []; + $log[] = [ + 'timestamp' => now()->toIso8601String(), + 'attempt' => $this->attempts, + 'error' => $error, + ]; + + $this->update([ + 'status' => self::STATUS_FAILED, + 'error_message' => $error, + 'provisioning_log' => $log, + ]); + } + + public function addLog(string $message, string $level = 'info'): void + { + $log = $this->provisioning_log ?? []; + $log[] = [ + 'timestamp' => now()->toIso8601String(), + 'level' => $level, + 'message' => $message, + ]; + + $this->update(['provisioning_log' => $log]); + } +} diff --git a/app/Models/RcServiceOrder.php b/app/Models/RcServiceOrder.php new file mode 100644 index 0000000..94ce352 --- /dev/null +++ b/app/Models/RcServiceOrder.php @@ -0,0 +1,93 @@ + 'integer', + 'term_months' => 'integer', + 'term_years' => 'integer', + 'expires_at' => 'datetime', + 'paid_at' => 'datetime', + 'submitted_at' => 'datetime', + 'last_synced_at' => 'datetime', + 'provisioned_at' => 'datetime', + 'meta' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function scopeVisibleToCustomer($query) + { + return $query->whereNotIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]); + } +} diff --git a/app/Models/ServerAgent.php b/app/Models/ServerAgent.php new file mode 100644 index 0000000..2675b98 --- /dev/null +++ b/app/Models/ServerAgent.php @@ -0,0 +1,72 @@ + 'array', + 'metadata' => 'array', + 'access_token_issued_at' => 'datetime', + 'registered_at' => 'datetime', + 'last_heartbeat_at' => 'datetime', + ]; + + protected $hidden = [ + 'bootstrap_token_hash', + 'access_token_hash', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function tasks(): HasMany + { + return $this->hasMany(ServerTask::class); + } + + public function sites(): HasMany + { + return $this->hasMany(ServerSite::class); + } + + public function databases(): HasMany + { + return $this->hasMany(ServerDatabase::class); + } + + public function files(): HasMany + { + return $this->hasMany(ServerFile::class); + } +} diff --git a/app/Models/ServerDatabase.php b/app/Models/ServerDatabase.php new file mode 100644 index 0000000..36daa61 --- /dev/null +++ b/app/Models/ServerDatabase.php @@ -0,0 +1,53 @@ + 'array', + ]; + + protected $hidden = [ + 'password_encrypted', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(ServerAgent::class, 'server_agent_id'); + } + + public function latestTask(): BelongsTo + { + return $this->belongsTo(ServerTask::class, 'latest_server_task_id'); + } + + public function site(): BelongsTo + { + return $this->belongsTo(ServerSite::class, 'server_site_id'); + } +} diff --git a/app/Models/ServerFile.php b/app/Models/ServerFile.php new file mode 100644 index 0000000..375b03f --- /dev/null +++ b/app/Models/ServerFile.php @@ -0,0 +1,48 @@ + 'boolean', + 'size_bytes' => 'integer', + 'metadata' => 'array', + 'last_synced_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(ServerAgent::class, 'server_agent_id'); + } + + public function latestTask(): BelongsTo + { + return $this->belongsTo(ServerTask::class, 'latest_server_task_id'); + } +} diff --git a/app/Models/ServerSite.php b/app/Models/ServerSite.php new file mode 100644 index 0000000..30f8d9d --- /dev/null +++ b/app/Models/ServerSite.php @@ -0,0 +1,60 @@ + 'array', + 'ssl_provisioned_at' => 'datetime', + 'ssl_expires_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(ServerAgent::class, 'server_agent_id'); + } + + public function latestTask(): BelongsTo + { + return $this->belongsTo(ServerTask::class, 'latest_server_task_id'); + } + + public function domainModel(): BelongsTo + { + return $this->belongsTo(Domain::class, 'domain_id'); + } + + public function databases(): HasMany + { + return $this->hasMany(ServerDatabase::class); + } +} diff --git a/app/Models/ServerTask.php b/app/Models/ServerTask.php new file mode 100644 index 0000000..a33f934 --- /dev/null +++ b/app/Models/ServerTask.php @@ -0,0 +1,98 @@ + 'array', + 'result' => 'array', + 'queued_at' => 'datetime', + 'available_at' => 'datetime', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'failed_at' => 'datetime', + 'last_heartbeat_at' => 'datetime', + 'locked_at' => 'datetime', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id'); + } + + public function agent(): BelongsTo + { + return $this->belongsTo(ServerAgent::class, 'server_agent_id'); + } + + public function sites(): HasMany + { + return $this->hasMany(ServerSite::class, 'latest_server_task_id'); + } + + public function databases(): HasMany + { + return $this->hasMany(ServerDatabase::class, 'latest_server_task_id'); + } + + public function files(): HasMany + { + return $this->hasMany(ServerFile::class, 'latest_server_task_id'); + } + + public function canRetry(bool $retryable = true): bool + { + return $retryable && $this->attempt_count < max(1, (int) $this->max_attempts); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..0db8623 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,52 @@ + */ + use HasApiTokens, HasFactory, Notifiable; + + protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password']; + + protected $hidden = ['password', 'remember_token']; + + protected function casts(): array + { + return ['email_verified_at' => 'datetime', 'password' => 'hashed']; + } + + /** Active team memberships (accounts this user can act within as a member). */ + public function memberships(): HasMany + { + return $this->hasMany(HostingTeamMember::class, 'user_id') + ->where('status', HostingTeamMember::STATUS_ACTIVE); + } + + /** Can this user act within the given account (own it, or active member)? */ + public function canAccessAccount(int $accountId): bool + { + return $accountId === $this->id + || $this->memberships()->where('account_id', $accountId)->exists(); + } + + /** Owner Users for every account this user can act in (self first). */ + public function accessibleAccounts(): Collection + { + $ids = $this->memberships()->pluck('account_id')->all(); + + return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values(); + } +} diff --git a/app/Models/VpsInstance.php b/app/Models/VpsInstance.php new file mode 100644 index 0000000..c0b79e3 --- /dev/null +++ b/app/Models/VpsInstance.php @@ -0,0 +1,72 @@ + 'array', + 'metadata' => 'array', + 'provisioned_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + protected $hidden = ['root_password_encrypted']; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(HostingPlan::class, 'hosting_plan_id'); + } + + public function snapshots(): HasMany + { + return $this->hasMany(VpsSnapshot::class); + } + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function scopeRunning($query) + { + return $query->where('status', 'running'); + } +} diff --git a/app/Models/Website.php b/app/Models/Website.php new file mode 100644 index 0000000..9905dcb --- /dev/null +++ b/app/Models/Website.php @@ -0,0 +1,126 @@ + 'array', + 'published_at' => 'datetime', + ]; + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function drafts(): HasMany + { + return $this->hasMany(WebsiteDraft::class); + } + + public function domains(): HasMany + { + return $this->hasMany(Domain::class); + } + + public function members(): HasMany + { + return $this->hasMany(WebsiteMember::class); + } + + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + public function leads(): HasMany + { + return $this->hasMany(Lead::class); + } + + public function mediaAssets(): HasMany + { + return $this->hasMany(MediaAsset::class); + } + + public function aiRequests(): HasMany + { + return $this->hasMany(AiRequest::class); + } + + public function publishes(): HasMany + { + return $this->hasMany(Publish::class); + } + + public function paymentSetting() + { + return $this->hasOne(WebsitePaymentSetting::class); + } + + public function wallet() + { + return $this->hasOne(WebsiteWallet::class); + } + + public function ecommerceProducts(): HasMany + { + return $this->hasMany(EcommerceProduct::class); + } + + public function ecommerceOrders(): HasMany + { + return $this->hasMany(EcommerceOrder::class); + } + + public function blogPosts(): HasMany + { + return $this->hasMany(BlogPost::class); + } + + public function blogCategories(): HasMany + { + return $this->hasMany(BlogCategory::class); + } + + public function contactMessages(): HasMany + { + return $this->hasMany(ContactMessage::class); + } + + public function newsletterSubscribers(): HasMany + { + return $this->hasMany(NewsletterSubscriber::class); + } + + public function newsletterCampaigns(): HasMany + { + return $this->hasMany(NewsletterCampaign::class); + } + + public function mailboxes(): HasMany + { + return $this->hasMany(Mailbox::class); + } +} diff --git a/app/Models/WebsiteMember.php b/app/Models/WebsiteMember.php new file mode 100644 index 0000000..a81be6b --- /dev/null +++ b/app/Models/WebsiteMember.php @@ -0,0 +1,28 @@ +belongsTo(Website::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Notifications/DomainVerifiedNotification.php b/app/Notifications/DomainVerifiedNotification.php new file mode 100644 index 0000000..666597c --- /dev/null +++ b/app/Notifications/DomainVerifiedNotification.php @@ -0,0 +1,45 @@ +subject("Domain Verified: {$this->domain->host} is now active!") + ->view('mail.notifications.domain-verified', [ + 'domain' => $this->domain, + 'manageUrl' => route('user.domains.show', $this->domain), + 'emailUrl' => route('user.mailboxes.index'), + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'Domain Verified', + 'message' => "Your domain {$this->domain->host} has been verified and is now active.", + 'icon' => 'success', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Notifications/HostingActivatedNotification.php b/app/Notifications/HostingActivatedNotification.php new file mode 100644 index 0000000..35d8bba --- /dev/null +++ b/app/Notifications/HostingActivatedNotification.php @@ -0,0 +1,53 @@ +subject('Your hosting is now active!') + ->view('mail.notifications.hosting-activated', [ + 'planName' => $this->planName, + 'activationDate' => $this->activationDate, + 'domainName' => $this->domainName, + 'manageUrl' => $this->manageUrl ?? route('hosting.index'), + ]); + } + + public function toArray($notifiable): array + { + $message = "Your {$this->planName} hosting plan is now active"; + if ($this->domainName) { + $message .= " for {$this->domainName}"; + } + + return [ + 'title' => 'Hosting Activated', + 'message' => $message . '.', + 'icon' => 'hosting', + 'url' => $this->manageUrl ?? route('hosting.index'), + ]; + } +} diff --git a/app/Notifications/HostingDeveloperAddedNotification.php b/app/Notifications/HostingDeveloperAddedNotification.php new file mode 100644 index 0000000..877bb0a --- /dev/null +++ b/app/Notifications/HostingDeveloperAddedNotification.php @@ -0,0 +1,54 @@ + $accountLabels + */ + public function __construct( + private string $ownerName, + private array $accountLabels, + private ?string $setupUrl = null + ) {} + + public function via($notifiable): array + { + return ['mail', 'database']; + } + + public function toMail($notifiable): MailMessage + { + return (new MailMessage()) + ->subject('You were added to a Ladill hosting team') + ->view('mail.notifications.hosting-developer-added', [ + 'developer' => $notifiable, + 'ownerName' => $this->ownerName, + 'accountLabels' => $this->accountLabels, + 'setupUrl' => $this->setupUrl, + 'loginUrl' => route('login'), + 'dashboardUrl' => route('dashboard'), + ]); + } + + public function toArray($notifiable): array + { + $accountCount = count($this->accountLabels); + $scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts'; + + return [ + 'title' => 'Hosting team access granted', + 'message' => "You were added by {$this->ownerName} to {$scope}.", + 'icon' => 'hosting', + 'url' => route('hosting.single-domain'), + ]; + } +} diff --git a/app/Notifications/HostingExpiringNotification.php b/app/Notifications/HostingExpiringNotification.php new file mode 100644 index 0000000..48e83cf --- /dev/null +++ b/app/Notifications/HostingExpiringNotification.php @@ -0,0 +1,68 @@ +subject($this->subjectLine()) + ->view('mail.notifications.hosting-expiring', [ + 'account' => $this->account, + 'daysRemaining' => $this->daysRemaining, + 'renewUrl' => route('hosting.accounts.show', $this->account), + ]); + } + + public function toArray($notifiable): array + { + $label = $this->account->primary_domain ?: $this->account->username; + + return [ + 'title' => $this->headline(), + 'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.", + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } + + private function subjectLine(): string + { + $label = $this->account->primary_domain ?: $this->account->username; + + return match (true) { + $this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}", + $this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days", + default => "Hosting renewal reminder: {$label}", + }; + } + + private function headline(): string + { + return match (true) { + $this->daysRemaining <= 1 => 'Hosting expires soon', + $this->daysRemaining <= 7 => 'Urgent hosting renewal', + default => 'Hosting renewal reminder', + }; + } +} diff --git a/app/Notifications/HostingResourceWarningNotification.php b/app/Notifications/HostingResourceWarningNotification.php new file mode 100644 index 0000000..ba80d04 --- /dev/null +++ b/app/Notifications/HostingResourceWarningNotification.php @@ -0,0 +1,47 @@ +subject($this->subjectLine) + ->greeting('Hosting resource update') + ->line($this->messageBody) + ->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username)) + ->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active'))) + ->action('Manage Hosting', route('hosting.accounts.show', $this->account)); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->subjectLine, + 'message' => $this->messageBody, + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } +} diff --git a/app/Notifications/HostingSuspendedNotification.php b/app/Notifications/HostingSuspendedNotification.php new file mode 100644 index 0000000..e164bde --- /dev/null +++ b/app/Notifications/HostingSuspendedNotification.php @@ -0,0 +1,48 @@ +subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username)) + ->view('mail.notifications.hosting-suspended', [ + 'account' => $this->account, + 'reason' => $this->reason, + 'dashboardUrl' => route('hosting.accounts.show', $this->account), + ]); + } + + public function toArray($notifiable): array + { + $label = $this->account->primary_domain ?: $this->account->username; + + return [ + 'title' => 'Hosting suspended', + 'message' => "Hosting for {$label} has been suspended. {$this->reason}", + 'icon' => 'hosting', + 'url' => route('hosting.accounts.show', $this->account), + ]; + } +} diff --git a/app/Notifications/SslExpiringNotification.php b/app/Notifications/SslExpiringNotification.php new file mode 100644 index 0000000..25e2940 --- /dev/null +++ b/app/Notifications/SslExpiringNotification.php @@ -0,0 +1,47 @@ +subject("SSL Certificate Expiring Soon: {$this->domain->host}") + ->view('mail.notifications.ssl-expiring', [ + 'domain' => $this->domain, + 'daysUntilExpiry' => $this->daysUntilExpiry, + 'expiresAt' => $this->domain->ssl_expires_at, + 'manageUrl' => route('user.domains.show', $this->domain), + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'SSL Certificate Expiring', + 'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.", + 'icon' => 'warning', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Notifications/SslProvisionedNotification.php b/app/Notifications/SslProvisionedNotification.php new file mode 100644 index 0000000..7d8246d --- /dev/null +++ b/app/Notifications/SslProvisionedNotification.php @@ -0,0 +1,45 @@ +subject("SSL Certificate Active: {$this->domain->host}") + ->view('mail.notifications.ssl-provisioned', [ + 'domain' => $this->domain, + 'expiresAt' => $this->domain->ssl_expires_at, + 'websiteUrl' => "https://{$this->domain->host}", + ]); + } + + public function toArray($notifiable): array + { + return [ + 'title' => 'SSL Certificate Active', + 'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!", + 'icon' => 'ssl', + 'url' => route('user.domains.show', $this->domain), + ]; + } +} diff --git a/app/Policies/HostingAccountPolicy.php b/app/Policies/HostingAccountPolicy.php new file mode 100644 index 0000000..08c5b9b --- /dev/null +++ b/app/Policies/HostingAccountPolicy.php @@ -0,0 +1,114 @@ +isOwner($user, $account); + } + + public function viewPanel(User $user, HostingAccount $account): bool + { + return $this->isOwner($user, $account) || $this->isDeveloper($user, $account); + } + + public function manageFiles(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function deleteFiles(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function viewDomains(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageDomains(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageDatabases(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function managePhp(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function useTerminal(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageSsl(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageCron(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function viewLogs(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function clearLogs(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageApps(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function viewSettings(User $user, HostingAccount $account): bool + { + return $this->viewPanel($user, $account); + } + + public function manageSettings(User $user, HostingAccount $account): bool + { + return $this->isOwner($user, $account); + } + + public function changePassword(User $user, HostingAccount $account): bool + { + return $this->isOwner($user, $account); + } + + private function isOwner(User $user, HostingAccount $account): bool + { + return (int) $account->user_id === (int) $user->id; + } + + private function isDeveloper(User $user, HostingAccount $account): bool + { + if ($this->isOwner($user, $account)) { + return false; + } + + if ($account->relationLoaded('teamMembers')) { + return $account->teamMembers->contains('user_id', $user->id); + } + + return $account->teamMembers() + ->where('user_id', $user->id) + ->exists(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..1e63fa5 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,21 @@ + $history + * @param array $context + */ + public function chat(string $message, array $history, array $context): string + { + if (! $this->enabled()) { + throw new RuntimeException('Afia is not configured.'); + } + + $provider = (string) config('afia.provider', 'openai'); + $model = (string) config('afia.model', 'gpt-4o-mini'); + $apiKey = (string) config('afia.api_key'); + + $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; + foreach (array_slice($history, -8) as $turn) { + $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; + $text = trim((string) ($turn['text'] ?? '')); + if ($text !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } + $messages[] = ['role' => 'user', 'content' => $message]; + + return $provider === 'anthropic' + ? $this->viaAnthropic($model, $apiKey, $messages) + : $this->viaOpenAi($model, $apiKey, $messages); + } + + private function viaOpenAi(string $model, string $apiKey, array $messages): string + { + $res = Http::withToken($apiKey)->acceptJson()->timeout(45) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'temperature' => 0.3, + 'max_tokens' => 600, + 'messages' => $messages, + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + private function viaAnthropic(string $model, string $apiKey, array $messages): string + { + $system = $messages[0]['content']; + $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); + + $res = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => 600, + 'system' => $system, + 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), + ]); + + if ($res->failed()) { + throw new RuntimeException('Anthropic request failed: '.$res->status()); + } + + return trim((string) $res->json('content.0.text', '')); + } + + /** @param array $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return match ((string) config('afia.product', 'hosting')) { + 'email' => $this->emailSystemPrompt($ctx), + default => $this->hostingSystemPrompt($ctx), + }; + } + + private function hostingSystemPrompt(string $ctx): string + { + return << port 993 (SSL); SMTP host mail. port 587 + (STARTTLS); username is the full email address. + + Facts: Mail is delivered by Ladill's mail servers. Prices show in GHS. Login/identity is via the Ladill + account (SSO). DNS changes can take time to propagate, so verification may not be instant. + + Rules: Only answer questions about Ladill Email — domains for email, DNS verification, mailboxes, webmail, + IMAP/SMTP setup, team, and billing for mailboxes. If asked about unrelated topics, briefly redirect. + Never invent passwords, DNS values, or prices — tell the user where to find them. If something needs a + verified domain or a funded wallet, say so. + + Current user context: + {$ctx} + PROMPT; + } +} diff --git a/app/Services/Billing/BillingClient.php b/app/Services/Billing/BillingClient.php new file mode 100644 index 0000000..4c7caef --- /dev/null +++ b/app/Services/Billing/BillingClient.php @@ -0,0 +1,87 @@ +token())->acceptJson()->timeout(10)->get($this->base().$path, $query); + $res->throw(); + + return (array) $res->json(); + } + + public function balanceMinor(string $publicId): int + { + return (int) ($this->get('/balance', ['user' => $publicId])['balance_minor'] ?? 0); + } + + public function canAfford(string $publicId, int $amountMinor): bool + { + return (bool) ($this->get('/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor])['affordable'] ?? false); + } + + public function serviceLedger(string $publicId, string $service): array + { + return $this->get('/service-ledger', ['user' => $publicId, 'service' => $service]); + } + + /** + * Debit the wallet. Returns true on success, false on insufficient balance + * (HTTP 402). Idempotent by $reference. + */ + public function debit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): bool + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([ + 'user' => $publicId, + 'amount_minor' => $amountMinor, + 'service' => $service, + 'source' => $source, + 'reference' => $reference, + 'service_id' => $serviceId, + 'description' => $description, + ], static fn ($v) => $v !== null)); + + if ($res->status() === 402) { + return false; + } + $res->throw(); + + return true; + } + + public function credit(string $publicId, int $amountMinor, string $service, string $source, string $reference, ?int $serviceId = null, ?string $description = null): array + { + $res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/credit', array_filter([ + 'user' => $publicId, + 'amount_minor' => $amountMinor, + 'service' => $service, + 'source' => $source, + 'reference' => $reference, + 'service_id' => $serviceId, + 'description' => $description, + ], static fn ($v) => $v !== null)); + $res->throw(); + + return (array) $res->json(); + } +} diff --git a/app/Services/Billing/PaystackService.php b/app/Services/Billing/PaystackService.php new file mode 100644 index 0000000..5fb1fdf --- /dev/null +++ b/app/Services/Billing/PaystackService.php @@ -0,0 +1,164 @@ +settingValue('paystack_public_key', config('services.paystack.public_key', '')); + } + + public function initializeTransaction(array $payload): array + { + return $this->initializeTransactionWithCredentials($payload); + } + + public function initializeTransactionWithCredentials(array $payload, ?string $secretKey = null, ?string $baseUrl = null): array + { + $response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl) + ->post('/transaction/initialize', $payload); + + return $this->extractData($response, 'Failed to initialize Paystack transaction.'); + } + + public function verifyTransaction(string $reference): array + { + return $this->verifyTransactionWithCredentials($reference); + } + + public function verifyTransactionWithCredentials(string $reference, ?string $secretKey = null, ?string $baseUrl = null): array + { + $response = $this->request(secretKey: $secretKey, baseUrl: $baseUrl) + ->get('/transaction/verify/' . urlencode($reference)); + + return $this->extractData($response, 'Failed to verify Paystack transaction.'); + } + + public function verifyWebhookSignature(string $rawBody, ?string $signature): bool + { + $secret = (string) $this->settingValue('paystack_webhook_secret', config('services.paystack.webhook_secret')); + if ($secret === '') { + $secret = (string) $this->settingValue('paystack_secret_key', config('services.paystack.secret_key')); + } + if ($secret === '' || !$signature) { + return false; + } + + $computed = hash_hmac('sha512', $rawBody, $secret); + return hash_equals($computed, $signature); + } + + protected function request(?string $secretKey = null, ?string $baseUrl = null) + { + $resolvedBaseUrl = rtrim((string) ($baseUrl ?: $this->settingValue('paystack_base_url', config('services.paystack.base_url', 'https://api.paystack.co'))), '/'); + $secret = $this->normalizeSecretKey( + (string) ($secretKey ?: $this->settingValue('paystack_secret_key', config('services.paystack.secret_key'))) + ); + + return Http::baseUrl($resolvedBaseUrl) + ->withToken($secret) + ->acceptJson() + ->asJson(); + } + + protected function settingValue(string $key, mixed $fallback = null): mixed + { + try { + if (!Schema::hasTable('platform_settings')) { + return $fallback; + } + + $setting = PlatformSetting::query() + ->where('key', $key) + ->where('is_active', true) + ->first(); + + return $setting ? ($setting->value['value'] ?? $fallback) : $fallback; + } catch (\Throwable) { + return $fallback; + } + } + + protected function extractData(Response $response, string $message): array + { + if ($response->failed()) { + throw new RuntimeException($message); + } + + $json = $response->json(); + if (!is_array($json) || !($json['status'] ?? false)) { + throw new RuntimeException((string) ($json['message'] ?? $message)); + } + + $data = $json['data'] ?? null; + return is_array($data) ? $data : []; + } + + /** + * List supported banks / mobile money providers. + * @param string $country e.g. 'ghana', 'nigeria' + * @param string $type 'nuban', 'mobile_money', or '' for all + */ + public function listBanks(string $country = 'ghana', string $type = '', ?string $currency = null): array + { + $query = ['perPage' => 200]; + if ($currency !== null && $currency !== '') { + $query['currency'] = strtoupper($currency); + } else { + $query['country'] = $country; + } + if ($type !== '') { + $query['type'] = $type; + } + $response = $this->request()->get('/bank', $query); + $json = $response->json(); + return is_array($json['data'] ?? null) ? $json['data'] : []; + } + + /** + * Create a transfer recipient (bank account or mobile money). + * @param array{type: string, name: string, account_number: string, bank_code: string, currency?: string} $payload + */ + public function createTransferRecipient(array $payload): array + { + $response = $this->request()->post('/transferrecipient', $payload); + return $this->extractData($response, 'Failed to create transfer recipient.'); + } + + /** + * Initiate a transfer to a recipient. + * @param array{source: string, amount: int, recipient: string, reason?: string, reference?: string} $payload + */ + public function initiateTransfer(array $payload): array + { + $response = $this->request()->post('/transfer', $payload); + return $this->extractData($response, 'Failed to initiate transfer.'); + } + + /** + * Verify a transfer by reference. + */ + public function verifyTransfer(string $reference): array + { + $response = $this->request()->get('/transfer/verify/' . urlencode($reference)); + return $this->extractData($response, 'Failed to verify transfer.'); + } + + protected function normalizeSecretKey(string $secret): string + { + $normalized = trim($secret); + + if (str_starts_with(strtolower($normalized), 'bearer ')) { + $normalized = trim(substr($normalized, 7)); + } + + return $normalized; + } +} diff --git a/app/Services/Currency/CurrencyConverterService.php b/app/Services/Currency/CurrencyConverterService.php new file mode 100644 index 0000000..2c509d9 --- /dev/null +++ b/app/Services/Currency/CurrencyConverterService.php @@ -0,0 +1,166 @@ +addMinutes(self::CACHE_TTL_MINUTES), function () { + return $this->fetchRateFromBase('USD') ?? self::FALLBACK_RATE; + }); + } + + public function getEurToGhsRate(): float + { + return Cache::remember(self::CACHE_KEY_EUR, now()->addMinutes(self::CACHE_TTL_MINUTES), function () { + return $this->fetchRateFromBase('EUR') ?? self::FALLBACK_EUR_RATE; + }); + } + + /** + * Convert USD amount to GHS. + */ + public function convertUsdToGhs(float $usdAmount): float + { + return $usdAmount * $this->getUsdToGhsRate(); + } + + /** + * Convert USD to GHS with markup percentage. + */ + public function convertWithMarkup(float $usdAmount, float $markupPercent = 35): float + { + $ghsAmount = $this->convertUsdToGhs($usdAmount); + return $ghsAmount * (1 + ($markupPercent / 100)); + } + + /** + * Convert USD cents to GHS pesewas with markup. + */ + public function convertCentsTopesewaswithMarkup(int $usdCents, float $markupPercent = 35): int + { + $usdAmount = $usdCents / 100; + $ghsAmount = $this->convertWithMarkup($usdAmount, $markupPercent); + return (int) round($ghsAmount * 100); + } + + private function fetchRateFromBase(string $base): ?float + { + $rate = $this->fetchFromExchangeRateApi($base) + ?? $this->fetchFromFreeCurrencyApi($base) + ?? $this->fetchFromOpenExchangeRates($base); + + if ($rate !== null) { + Log::info('Currency rate fetched', ['base' => $base, 'ghs' => $rate]); + } + + return $rate; + } + + private function fetchFromExchangeRateApi(string $base): ?float + { + try { + $response = Http::timeout(5)->get("https://api.exchangerate-api.com/v4/latest/{$base}"); + + if ($response->successful()) { + $data = $response->json(); + return (float) ($data['rates']['GHS'] ?? null) ?: null; + } + } catch (\Throwable $e) { + Log::warning('ExchangeRateApi fetch failed', ['base' => $base, 'error' => $e->getMessage()]); + } + + return null; + } + + private function fetchFromFreeCurrencyApi(string $base): ?float + { + $apiKey = config('services.freecurrencyapi.key'); + + if (empty($apiKey)) { + return null; + } + + try { + $response = Http::timeout(5)->get('https://api.freecurrencyapi.com/v1/latest', [ + 'apikey' => $apiKey, + 'base_currency' => $base, + 'currencies' => 'GHS', + ]); + + if ($response->successful()) { + $data = $response->json(); + return (float) ($data['data']['GHS'] ?? null) ?: null; + } + } catch (\Throwable $e) { + Log::warning('FreeCurrencyApi fetch failed', ['base' => $base, 'error' => $e->getMessage()]); + } + + return null; + } + + private function fetchFromOpenExchangeRates(string $base): ?float + { + $appId = config('services.openexchangerates.app_id'); + + if (empty($appId)) { + return null; + } + + // Free tier only supports USD as base + if ($base !== 'USD') { + return null; + } + + try { + $response = Http::timeout(5)->get('https://openexchangerates.org/api/latest.json', [ + 'app_id' => $appId, + 'symbols' => 'GHS', + ]); + + if ($response->successful()) { + $data = $response->json(); + return (float) ($data['rates']['GHS'] ?? null) ?: null; + } + } catch (\Throwable $e) { + Log::warning('OpenExchangeRates fetch failed', ['base' => $base, 'error' => $e->getMessage()]); + } + + return null; + } + + /** + * Force refresh the cached rate. + */ + public function refreshRate(): float + { + Cache::forget(self::CACHE_KEY); + Cache::forget(self::CACHE_KEY_EUR); + return $this->getUsdToGhsRate(); + } + + /** + * Get the current cached rate info. + */ + public function getRateInfo(): array + { + return [ + 'rate' => $this->getUsdToGhsRate(), + 'from' => 'USD', + 'to' => 'GHS', + 'cached' => Cache::has(self::CACHE_KEY), + 'fallback' => self::FALLBACK_RATE, + ]; + } +} diff --git a/app/Services/Dns/PowerDnsClient.php b/app/Services/Dns/PowerDnsClient.php new file mode 100644 index 0000000..5391b95 --- /dev/null +++ b/app/Services/Dns/PowerDnsClient.php @@ -0,0 +1,304 @@ +dkimKeys() + ->where('status', 'active') + ->latest('id') + ->value('selector'); + + $publicKey = $domain->dkimKeys() + ->where('status', 'active') + ->latest('id') + ->value('public_key_txt'); + + if (! $selector || ! $publicKey) { + throw new \RuntimeException('Missing DKIM key for '.$domain->host); + } + } + + $zoneName = $this->zoneName($domain); + $rrsets = $this->buildRRSets($domain, $selector, $publicKey); + + $url = rtrim(config('pdns.api_url'), '/')."/servers/{$this->server()}/zones"; + + \Illuminate\Support\Facades\Log::info('PDNS API: POST '.$url, [ + 'zone' => $zoneName, + 'rrset_count' => count($rrsets), + ]); + + $response = $this->httpClient()->post("servers/{$this->server()}/zones", [ + 'name' => $zoneName, + 'kind' => 'Master', + 'rrsets' => $rrsets, + ]); + + \Illuminate\Support\Facades\Log::info('PDNS API: Response', [ + 'status' => $response->status(), + 'body' => mb_substr($response->body(), 0, 500), + ]); + + if ($response->status() === 409) { + // Zone already exists — patch only the managed records so any + // custom records (e.g. mail A, additional subdomains) are preserved. + return $this->patchZone($zoneName, $rrsets); + } + + if ($response->failed()) { + $response->throw(); + } + + return $response->json() ?? ['status' => 'created']; + } + + public function ensureSlaveZone(Domain $domain, array $masters): array + { + if (empty($masters)) { + throw new \RuntimeException('No master IPs configured for slave zone'); + } + + $slaveApiUrl = config('pdns.slave_api_url'); + if (! $slaveApiUrl) { + throw new \RuntimeException('PDNS_SLAVE_API_URL is not configured'); + } + + $zoneName = $this->zoneName($domain); + $slaveServer = config('pdns.slave_server', 'localhost'); + + $response = $this->slaveHttpClient()->post("servers/{$slaveServer}/zones", [ + 'name' => $zoneName, + 'kind' => 'Slave', + 'masters' => array_values($masters), + ]); + + if ($response->status() === 409) { + return ['status' => 'already_exists']; + } + + if ($response->failed()) { + $response->throw(); + } + + return $response->json() ?? ['status' => 'created']; + } + + /** + * Upsert specific records into an already-existing zone on the master, + * without disturbing any other records in the zone (changetype=REPLACE + * is scoped to each name+type rrset). + * + * @param string $zoneName e.g. "ladill.com" (trailing dot optional) + * @param array}> $records + * + * @throws \Illuminate\Http\Client\RequestException + */ + public function upsertRecords(string $zoneName, array $records): array + { + if ($records === []) { + return ['status' => 'noop']; + } + + $zoneName = rtrim($zoneName, '.').'.'; + + $rrsets = array_map(function (array $record): array { + return [ + 'name' => rtrim($record['name'], '.').'.', + 'type' => strtoupper($record['type'] ?? 'A'), + 'ttl' => (int) ($record['ttl'] ?? 3600), + 'changetype' => 'REPLACE', + 'records' => array_map( + static fn (string $content): array => ['content' => $content, 'disabled' => false], + array_values($record['contents']), + ), + ]; + }, $records); + + $response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [ + 'rrsets' => $rrsets, + ]); + + if ($response->failed()) { + $response->throw(); + } + + return ['status' => 'updated', 'count' => count($rrsets)]; + } + + public function deleteZone(Domain $domain): void + { + $zoneName = $this->zoneName($domain); + + $response = $this->httpClient()->delete("servers/{$this->server()}/zones/{$zoneName}"); + + // 204 = deleted, 404 = already gone — both are fine. + if ($response->failed() && $response->status() !== 404) { + $response->throw(); + } + } + + private function patchZone(string $zoneName, array $rrsets): array + { + $patchSets = array_map(function (array $rrset) { + $rrset['changetype'] = 'REPLACE'; + return $rrset; + }, $rrsets); + + $response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [ + 'rrsets' => $patchSets, + ]); + + if ($response->failed()) { + $response->throw(); + } + + return $response->json() ?? ['status' => 'updated']; + } + + private function buildRRSets(Domain $domain, string $selector, string $publicKey): array + { + $records = $this->blueprints->managedRecords($domain, $selector, $publicKey); + $owner = rtrim($domain->host, '.').'.'; + $nameservers = $this->blueprints->expectedNameservers(); + $groups = []; + + $soaContent = sprintf( + 'ns1.ladill.com. hostmaster.ladill.com. %d 10800 3600 604800 3600', + now()->format('U') + ); + + $groups[$owner.'|SOA'] = [ + 'name' => $owner, + 'type' => 'SOA', + 'ttl' => 3600, + 'records' => [ + ['content' => $soaContent, 'disabled' => false], + ], + ]; + + $nsRecords = []; + foreach ($nameservers as $ns) { + $nsRecords[] = ['content' => rtrim($ns, '.').'.', 'disabled' => false]; + } + if ($nsRecords !== []) { + $groups[$owner.'|NS'] = [ + 'name' => $owner, + 'type' => 'NS', + 'ttl' => 3600, + 'records' => $nsRecords, + ]; + } + + foreach ($records as $record) { + $name = $this->fqdn($domain->host, (string) ($record['name'] ?? '@')); + $type = strtoupper((string) ($record['type'] ?? 'TXT')); + $key = $name.'|'.$type; + + $groups[$key]['name'] = $name; + $groups[$key]['type'] = $type; + $groups[$key]['ttl'] = (int) ($record['ttl'] ?? 3600); + + $value = (string) ($record['value'] ?? ''); + if (trim($value) === '@') { + $value = $domain->host; + } + $content = $this->formatContent($value, $type, (int) ($record['priority'] ?? 0)); + $groups[$key]['records'][] = [ + 'content' => $content, + 'disabled' => false, + ]; + } + + return array_values($groups); + } + + private function formatContent(string $value, string $type, int $priority = 0): string + { + $value = trim($value); + if ($value === '') { + return $value; + } + + return match ($type) { + 'MX' => $priority.' '.rtrim($value, '.').'.', + 'CNAME', 'NS', 'PTR' => rtrim($value, '.').'.', + 'TXT' => '"'.trim($value, '"').'"', + default => $value, + }; + } + + private function fqdn(string $host, string $name): string + { + $name = trim($name); + $zone = rtrim($host, '.').'.'; + + if ($name === '' || $name === '@') { + return $zone; + } + + return trim($name, '.').'.'.$zone; + } + + private function zoneName(Domain $domain): string + { + return rtrim($domain->host, '.').'.'; + } + + private function httpClient() + { + $base = rtrim(config('pdns.api_url'), '/'); + + // Ensure the /api/v1 prefix is present. + if (! str_contains($base, '/api/v1')) { + $base .= '/api/v1'; + } + + return Http::withHeaders([ + 'X-API-Key' => config('pdns.api_key'), + ])->baseUrl($base)->timeout(config('pdns.timeout')); + } + + private function slaveHttpClient() + { + $base = rtrim(config('pdns.slave_api_url'), '/'); + + if (! str_contains($base, '/api/v1')) { + $base .= '/api/v1'; + } + + return Http::withHeaders([ + 'X-API-Key' => config('pdns.slave_api_key'), + ])->baseUrl($base)->timeout(config('pdns.timeout')); + } + + private function server(): string + { + return config('pdns.server', 'localhost'); + } +} diff --git a/app/Services/Domain/DomainClient.php b/app/Services/Domain/DomainClient.php new file mode 100644 index 0000000..f696c25 --- /dev/null +++ b/app/Services/Domain/DomainClient.php @@ -0,0 +1,36 @@ +> */ + public function listForUser(string $publicId): array + { + if ($this->base() === '' || $this->token() === '') { + return []; + } + + $res = Http::withToken($this->token())->acceptJson()->timeout(10) + ->get($this->base().'/domains', ['user' => $publicId]); + + if ($res->failed()) { + return []; + } + + return (array) ($res->json('data') ?? $res->json() ?? []); + } +} diff --git a/app/Services/Domain/DomainDkimService.php b/app/Services/Domain/DomainDkimService.php new file mode 100644 index 0000000..61aa505 --- /dev/null +++ b/app/Services/Domain/DomainDkimService.php @@ -0,0 +1,82 @@ +where('domain_id', $domain->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + if ($existing) { + return $existing; + } + + $selectorPrefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill'); + $selector = Str::lower(trim($selectorPrefix)) ?: 'ladill'; + $selector .= '1'; + + $publicKey = ''; + $privateKey = null; + + if (function_exists('openssl_pkey_new')) { + $resource = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource !== false) { + openssl_pkey_export($resource, $privateOut); + $details = openssl_pkey_get_details($resource); + $publicPem = (string) ($details['key'] ?? ''); + $publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: ''; + $privateKey = $privateOut ?: null; + } + } + + if ($publicKey === '') { + $publicKey = Str::upper(Str::random(360)); + } + + $privatePath = null; + if (is_string($privateKey) && trim($privateKey) !== '') { + $safeHost = Str::slug($domain->host, '_'); + $directory = storage_path('app/mail/dkim'); + File::ensureDirectoryExists($directory); + $privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key'; + File::put($privatePath, $privateKey); + } + + $key = DomainDkimKey::query()->create([ + 'domain_id' => $domain->id, + 'selector' => $selector, + 'public_key_txt' => $publicKey, + 'private_key_path' => $privatePath, + 'status' => 'active', + 'generated_at' => now(), + ]); + + Log::info('Generated DKIM key for domain', ['domain_id' => $domain->id, 'selector' => $selector]); + + return $key; + } + + public function activeKey(Domain $domain): ?DomainDkimKey + { + return DomainDkimKey::query() + ->where('domain_id', $domain->id) + ->where('status', 'active') + ->latest('id') + ->first(); + } +} diff --git a/app/Services/Domain/DomainDnsBlueprintService.php b/app/Services/Domain/DomainDnsBlueprintService.php new file mode 100644 index 0000000..6a27b72 --- /dev/null +++ b/app/Services/Domain/DomainDnsBlueprintService.php @@ -0,0 +1,176 @@ +map(fn ($value) => strtolower(trim((string) $value, ". \t\n\r\0\x0B"))) + ->filter() + ->unique() + ->values() + ->all(); + } + + public function mailSetupInstructions(): array + { + return [ + 'imap' => [ + 'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'), + 'port' => (int) config('mailinfra.imap_port', 993), + 'security' => 'SSL/TLS', + 'username' => 'full_email', + ], + 'smtp' => [ + 'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'), + 'port' => (int) config('mailinfra.smtp_port', 587), + 'security' => 'STARTTLS', + 'authentication' => true, + 'username' => 'full_email', + ], + 'smtp_ssl' => [ + 'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'), + 'port' => (int) config('mailinfra.smtp_ssl_port', 465), + 'security' => 'SSL/TLS', + ], + ]; + } + + public function managedRecords(Domain $domain, string $selector, string $publicKey): array + { + $mailHost = strtolower(trim((string) config('mailinfra.mail_host', 'mail.ladill.com'))); + $autodiscoverHost = strtolower(trim((string) config('mailinfra.autodiscover_host', 'autodiscover.ladill.com'))); + $autoconfigHost = strtolower(trim((string) config('mailinfra.autoconfig_host', 'autoconfig.ladill.com'))); + $ttl = (int) config('mailinfra.default_ttl', 3600); + $websiteIp = trim((string) config('mailinfra.website_a_record', '')); + $wwwTarget = trim((string) config('mailinfra.website_www_cname', '@')); + $rua = trim((string) config('mailinfra.dmarc_rua', 'mailto:dmarc@ladill.com')); + + $records = []; + + // Always include A record for apex domain (use configured IP or placeholder) + $records[] = [ + 'name' => '@', + 'type' => 'A', + 'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149', + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Website apex record', + ]; + + // Always include www record (CNAME to @ or A record to same IP) + if ($wwwTarget !== '' && $wwwTarget !== '@') { + $records[] = [ + 'name' => 'www', + 'type' => 'CNAME', + 'value' => $wwwTarget, + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Website www record', + ]; + } else { + // Default: www points to same IP as apex + $records[] = [ + 'name' => 'www', + 'type' => 'A', + 'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149', + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Website www record', + ]; + } + + $records[] = [ + 'name' => '@', + 'type' => 'MX', + 'value' => $mailHost.'.', + 'ttl' => $ttl, + 'priority' => 10, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Primary mail exchanger', + ]; + + $records[] = [ + 'name' => '@', + 'type' => 'TXT', + 'value' => (string) config('mailinfra.spf_customer_txt'), + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'SPF policy', + ]; + + $records[] = [ + 'name' => $selector.'._domainkey', + 'type' => 'TXT', + 'value' => sprintf('v=DKIM1; k=rsa; p=%s', $publicKey), + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'DKIM public key', + ]; + + $records[] = [ + 'name' => '_dmarc', + 'type' => 'TXT', + 'value' => sprintf('v=DMARC1; p=none; rua=%s; adkim=s; aspf=s', $rua), + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'DMARC policy', + ]; + + $records[] = [ + 'name' => 'autodiscover', + 'type' => 'CNAME', + 'value' => $autodiscoverHost.'.', + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Autodiscover helper', + ]; + + $records[] = [ + 'name' => 'autoconfig', + 'type' => 'CNAME', + 'value' => $autoconfigHost.'.', + 'ttl' => $ttl, + 'priority' => null, + 'source' => 'managed', + 'status' => 'active', + 'notes' => 'Autoconfig helper', + ]; + + return $records; + } + + public function manualDnsPack(Domain $domain, string $selector, string $publicKey): array + { + return collect($this->managedRecords($domain, $selector, $publicKey)) + ->map(function (array $record) { + $record['source'] = 'required_manual'; + $record['status'] = 'pending'; + + return $record; + }) + ->values() + ->all(); + } +} diff --git a/app/Services/Domain/DomainPricingService.php b/app/Services/Domain/DomainPricingService.php new file mode 100644 index 0000000..d8d40dd --- /dev/null +++ b/app/Services/Domain/DomainPricingService.php @@ -0,0 +1,285 @@ +getAllTldPricing(); + + if (! isset($allPricing[$tld])) { + return null; + } + + return $allPricing[$tld]; + } + + /** + * Get pricing for multiple TLDs. + * + * @return array + */ + public function getPricingForTlds(array $tlds): array + { + $allPricing = $this->getAllTldPricing(); + $result = []; + + foreach ($tlds as $tld) { + $tld = ltrim(strtolower(trim($tld)), '.'); + if (isset($allPricing[$tld])) { + $result[$tld] = $allPricing[$tld]; + } + } + + return $result; + } + + /** + * Get only TLDs that currently have live Dynadot pricing. + * + * @return list + */ + public function getLivePricedTlds(): array + { + return array_keys($this->getLiveDynadotPricing()); + } + + /** + * Get all TLD pricing with conversion and markup applied. + * Price overrides from DomainPricing model take precedence. + * + * @return array + */ + public function getAllTldPricing(): array + { + $cacheKey = self::TLD_PRICING_CACHE_KEY . ':ghs'; + + return Cache::remember($cacheKey, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () { + $usdPricing = $this->fetchDynadotPricing(); + $overrides = DomainPricing::getOverrides(); + + if (empty($usdPricing) && empty($overrides)) { + return []; + } + + $markup = $this->getMarkupPercent(); + $usdRate = $this->currency->getUsdToGhsRate(); + $converted = []; + + // First, add all Dynadot prices with conversion + foreach ($usdPricing as $tld => $prices) { + $converted[$tld] = [ + 'register' => $this->convertPrice($prices['register'] ?? 0, $markup), + 'renew' => $this->convertPrice($prices['renew'] ?? 0, $markup), + 'transfer' => $this->convertPrice($prices['transfer'] ?? 0, $markup), + 'currency' => 'GHS', + 'usd_rate' => $usdRate, + 'usd_register' => $prices['register'] ?? 0, + 'usd_renew' => $prices['renew'] ?? 0, + 'usd_transfer' => $prices['transfer'] ?? 0, + 'is_override' => false, + ]; + } + + // Apply overrides (manual GHS prices take precedence) + foreach ($overrides as $tld => $override) { + $converted[$tld] = [ + 'register' => $override['register'], + 'renew' => $override['renew'], + 'transfer' => $override['transfer'], + 'currency' => 'GHS', + 'usd_rate' => $usdRate, + 'usd_register' => null, + 'usd_renew' => null, + 'usd_transfer' => null, + 'is_override' => true, + ]; + } + + return $converted; + }); + } + + /** + * Convert USD cents to GHS pesewas with markup. + */ + private function convertPrice(int $usdCents, float $markupPercent): int + { + return $this->currency->convertCentsTopesewaswithMarkup($usdCents, $markupPercent); + } + + /** + * Fetch TLD pricing from Dynadot API. + * + * @return array + */ + private function fetchDynadotPricing(): array + { + $livePricing = $this->getLiveDynadotPricing(); + + if ($livePricing !== []) { + return $livePricing; + } + + return $this->getFallbackPricing(); + } + + /** + * Fallback pricing in USD cents for common TLDs. + */ + private function getFallbackPricing(): array + { + return [ + 'com' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099], + 'net' => ['register' => 1199, 'renew' => 1199, 'transfer' => 1199], + 'org' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099], + 'io' => ['register' => 3999, 'renew' => 3999, 'transfer' => 3999], + 'co' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999], + 'info' => ['register' => 1899, 'renew' => 1899, 'transfer' => 1899], + 'biz' => ['register' => 1699, 'renew' => 1699, 'transfer' => 1699], + 'xyz' => ['register' => 1299, 'renew' => 1299, 'transfer' => 1299], + 'online' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999], + 'store' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999], + 'tech' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999], + 'site' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999], + ]; + } + + /** + * Fetch raw live TLD pricing from Dynadot without fallback expansion. + * + * @return array + */ + private function getLiveDynadotPricing(): array + { + return Cache::remember(self::LIVE_TLD_PRICING_CACHE_KEY, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () { + if (! $this->dynadot->isConfigured()) { + Log::warning('DomainPricingService: Dynadot not configured'); + + return []; + } + + try { + $result = $this->dynadot->getTldPricing(); + + if (! ($result['success'] ?? false) || empty($result['pricing'])) { + Log::warning('DomainPricingService: Failed to fetch Dynadot pricing', $result); + + return []; + } + + return $result['pricing']; + } catch (\Throwable $e) { + Log::error('DomainPricingService: Exception fetching pricing', ['error' => $e->getMessage()]); + + return []; + } + }); + } + + /** + * Format price for display. + */ + public function formatPrice(int $pesewas): string + { + return 'GH₵ ' . number_format($pesewas / 100, 2); + } + + public function convertRegistrarAmountToGhsMinor(int $amountMinor, string $currency): ?int + { + $currency = strtoupper(trim($currency)); + + if ($currency === 'GHS') { + return $amountMinor; + } + + if ($currency === 'USD') { + return $this->convertPrice($amountMinor, $this->getMarkupPercent()); + } + + return null; + } + + /** + * Get price info for a domain (for checkout/cart). + */ + public function getDomainPrice(string $domain, string $action = 'register'): ?array + { + $parts = explode('.', strtolower($domain), 2); + + if (count($parts) < 2) { + return null; + } + + $tld = $parts[1]; + $pricing = $this->getPricingForTld($tld); + + if (! $pricing) { + return null; + } + + $priceKey = match ($action) { + 'renew' => 'renew', + 'transfer' => 'transfer', + default => 'register', + }; + + return [ + 'domain' => $domain, + 'tld' => $tld, + 'action' => $action, + 'amount_minor' => $pricing[$priceKey], + 'currency' => 'GHS', + 'price_label' => $this->formatPrice($pricing[$priceKey]), + ]; + } + + /** + * Clear cached pricing. + */ + public function clearCache(): void + { + Cache::forget(self::TLD_PRICING_CACHE_KEY . ':ghs'); + Cache::forget(self::LIVE_TLD_PRICING_CACHE_KEY); + } + + /** + * Get current exchange rate info. + */ + public function getExchangeRateInfo(): array + { + return [ + ...$this->currency->getRateInfo(), + 'markup_percent' => $this->getMarkupPercent(), + ]; + } +} diff --git a/app/Services/Domain/DomainRegistrarService.php b/app/Services/Domain/DomainRegistrarService.php new file mode 100644 index 0000000..7c38444 --- /dev/null +++ b/app/Services/Domain/DomainRegistrarService.php @@ -0,0 +1,2020 @@ + + */ + private array $tldProductKeys; + + public function __construct() + { + $this->apiUrl = rtrim($this->configuredApiUrl(), '/'); + $this->domainCheckApiUrl = rtrim($this->configuredDomainCheckApiUrl(), '/'); + $this->authUserId = trim((string) config('mailinfra.registrar_auth_userid')); + $this->apiKey = trim((string) config('mailinfra.registrar_api_key')); + $this->customerId = trim((string) config('mailinfra.registrar_customer_id')); + $this->defaultContactId = trim((string) config('mailinfra.registrar_default_contact_id')); + $this->sellingCurrency = strtoupper(trim((string) config('mailinfra.registrar_selling_currency', 'GHS'))); + $this->tldProductKeys = DomainConfig::resellerClubTldProductKeys(); + } + + public function isConfigured(): bool + { + return $this->authUserId !== '' && $this->apiKey !== ''; + } + + /** + * Resolve (find or create) a ResellerClub contact for the given user and customer. + * + * @return array{success: bool, contact_id?: string, message?: string} + */ + public function resolveContactForCustomer(User $user, string $customerId): array + { + if ($user->rc_contact_id) { + return ['success' => true, 'contact_id' => (string) $user->rc_contact_id]; + } + + $defaults = (array) config('mailinfra.resellerclub_customer_defaults', []); + $email = strtolower(trim((string) $user->email)); + $name = trim(implode(' ', array_filter([ + trim((string) ($user->first_name ?? '')), + trim((string) ($user->last_name ?? '')), + ]))); + + if ($name === '') { + $name = trim((string) $user->name) !== '' ? (string) $user->name : $email; + } + + $found = $this->searchContact($customerId, $email); + if ($found !== null) { + $user->forceFill(['rc_contact_id' => $found])->save(); + + return ['success' => true, 'contact_id' => $found]; + } + + try { + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.'/contacts/add.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'name' => $name, + 'company' => trim((string) ($user->company ?? '')) !== '' + ? (string) $user->company + : (string) ($defaults['company'] ?? config('app.name', 'Ladill')), + 'email' => $email, + 'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== '' + ? (string) $user->address_line_1 + : (string) ($defaults['address_line_1'] ?? 'Not provided'), + 'city' => trim((string) ($user->city ?? '')) !== '' + ? (string) $user->city + : (string) ($defaults['city'] ?? 'Accra'), + 'state' => trim((string) ($user->state ?? '')) !== '' + ? (string) $user->state + : (string) ($defaults['state'] ?? 'Not Applicable'), + 'country' => trim((string) ($user->country ?? '')) !== '' + ? strtoupper((string) $user->country) + : (string) ($defaults['country'] ?? 'GH'), + 'zipcode' => trim((string) ($user->zipcode ?? '')) !== '' + ? (string) $user->zipcode + : (string) ($defaults['zipcode'] ?? '00000'), + 'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== '' + ? ltrim((string) $user->phone_cc, '+') + : (string) ($defaults['phone_cc'] ?? '233'), + 'phone' => trim((string) ($user->phone ?? '')) !== '' + ? (string) $user->phone + : (string) ($defaults['phone'] ?? '000000000'), + 'customer-id' => $customerId, + 'type' => 'Contact', + ]) + ); + + $body = trim((string) $response->body()); + + if ($response->failed()) { + Log::warning('DomainRegistrarService: contact creation failed', [ + 'user_id' => $user->id, + 'customer_id' => $customerId, + 'status' => $response->status(), + 'body' => $body, + ]); + + return ['success' => false, 'message' => $this->extractResponseMessage($response, 'Could not create the domain contact.')]; + } + + $contactId = trim($body, "\" \t\n\r"); + + if ($contactId === '' || ! is_numeric($contactId)) { + Log::warning('DomainRegistrarService: unexpected contact creation response', [ + 'user_id' => $user->id, + 'body' => $body, + ]); + + return ['success' => false, 'message' => 'Could not create the domain contact.']; + } + + $user->forceFill(['rc_contact_id' => $contactId])->save(); + + return ['success' => true, 'contact_id' => $contactId]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: resolveContactForCustomer failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not create the domain contact.']; + } + } + + /** + * Search for an existing contact under a customer by email. + */ + private function searchContact(string $customerId, string $email): ?string + { + try { + $response = $this->rcReadClient()->get($this->apiUrl.'/contacts/search.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'customer-id' => $customerId, + 'no-of-records' => 1, + 'page-no' => 1, + 'type' => 'Contact', + 'email' => $email, + ]); + + $data = $response->json(); + + if (! $response->successful() || ! is_array($data)) { + return null; + } + + foreach ($data as $row) { + if (! is_array($row)) { + continue; + } + + $contactId = (string) ($row['entity.entityid'] ?? ''); + if ($contactId !== '') { + return $contactId; + } + } + + return null; + } catch (\Throwable $e) { + Log::warning('DomainRegistrarService: searchContact failed', [ + 'customer_id' => $customerId, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + /** + * @param array $defaultTlds + * @return array{ + * success: bool, + * results?: array, + * exact?: bool, + * keyword?: string, + * tlds?: array, + * message?: string + * } + */ + public function checkAvailability(string $query, array $defaultTlds): array + { + $parsed = $this->parseSearchQuery($query, $defaultTlds); + + if (! $parsed['valid']) { + return [ + 'success' => false, + 'message' => $parsed['message'], + ]; + } + + try { + $response = $this->rcReadClient()->get( + $this->buildRepeatedQueryUrl( + $this->domainCheckApiUrl.'/domains/available.json', + [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => [$parsed['keyword']], + 'tlds' => $parsed['tlds'], + ] + ) + ); + + if ($response->failed()) { + Log::warning('DomainRegistrarService: availability request failed', [ + 'query' => $query, + 'status' => $response->status(), + 'response' => Str::limit($response->body(), 500), + ]); + + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Could not check domain availability right now.'), + ]; + } + + $data = $response->json(); + + if (! is_array($data)) { + Log::warning('DomainRegistrarService: unexpected availability response', [ + 'query' => $query, + 'response' => Str::limit($response->body(), 500), + ]); + + return [ + 'success' => false, + 'message' => 'The registrar returned an unexpected response. Please try again in a moment.', + ]; + } + + $pricingMap = $this->preloadPricingForTlds((array) $parsed['tlds']); + + return [ + 'success' => true, + 'exact' => $parsed['exact'], + 'keyword' => $parsed['keyword'], + 'tlds' => $parsed['tlds'], + 'results' => $this->mapAvailabilityResults($parsed['keyword'], $parsed['tlds'], $data, $pricingMap), + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: availability check failed', [ + 'query' => $query, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => 'Could not reach the domain registry right now. Please try again in a moment.', + ]; + } + } + + /** + * @param array $tlds + * @return array{ + * success: bool, + * results?: array, + * message?: string + * } + */ + public function suggestDomains(string $keyword, array $tlds = [], ?string $excludeDomain = null): array + { + $keyword = $this->normalizeSearchQuery($keyword); + + if ($keyword === '' || str_contains($keyword, '.')) { + return [ + 'success' => false, + 'message' => 'Enter a base name such as "ladill" to get suggestions.', + ]; + } + + try { + $payload = [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'keyword' => $keyword, + 'exact-match' => false, + 'adult' => false, + ]; + + if ($tlds !== []) { + $payload['tld-only'] = array_values(array_unique(array_filter($tlds))); + } + + $response = $this->rcReadClient()->get( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/v5/suggest-names.json', $payload) + ); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Could not load domain suggestions right now.'), + ]; + } + + $data = $response->json(); + + if (! is_array($data)) { + return [ + 'success' => false, + 'message' => 'The registrar returned an unexpected suggestion response.', + ]; + } + + $normalizedTlds = array_values(array_unique(array_map( + fn ($tld) => ltrim(strtolower((string) $tld), '.'), + array_filter($tlds) + ))); + + $pricingMap = $normalizedTlds !== [] ? $this->preloadPricingForTlds($normalizedTlds) : []; + + return [ + 'success' => true, + 'results' => $this->mapSuggestedDomains($data, $excludeDomain, $pricingMap), + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: suggestion lookup failed', [ + 'keyword' => $keyword, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => 'Could not load domain suggestions right now.', + ]; + } + } + + /** + * @return array{success: bool, domain?: string, transferable?: bool, message?: string} + */ + public function validateTransfer(string $domain): array + { + $domain = $this->normalizeFullDomain($domain); + + if ($domain === null) { + return [ + 'success' => false, + 'message' => 'Enter a valid domain name to transfer.', + ]; + } + + try { + $response = $this->rcReadClient()->get($this->apiUrl.'/domains/validate-transfer.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => $domain, + ]); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Could not validate this transfer right now.'), + ]; + } + + $transferable = $this->booleanFromResponse($response); + + return [ + 'success' => true, + 'domain' => $domain, + 'transferable' => $transferable, + 'message' => $transferable + ? 'This domain can be transferred.' + : 'This domain cannot be transferred right now. Check the auth code, lock status, and recent registration changes.', + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: transfer validation failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => 'Could not validate this transfer right now.', + ]; + } + } + + /** + * Register a domain via the registrar API. + * + * @return array{success: bool, message?: string, order_id?: string} + */ + public function registerDomain(string $domain, int $years = 1, ?string $customerId = null, ?string $contactId = null): array + { + $domain = $this->normalizeFullDomain($domain) ?? $domain; + $contact = $contactId ?: $this->defaultContactId; + + try { + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/register.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => $domain, + 'years' => $years, + 'ns' => (array) config('mailinfra.nameservers'), + 'customer-id' => $customerId ?: $this->customerId, + 'reg-contact-id' => $contact, + 'admin-contact-id' => $contact, + 'tech-contact-id' => $contact, + 'billing-contact-id' => $contact, + 'invoice-option' => 'NoInvoice', + 'protect-privacy' => true, + 'auto-renew' => true, + ]) + ); + + $data = $response->json(); + + if ($response->failed()) { + Log::warning('DomainRegistrarService: registration failed', [ + 'domain' => $domain, + 'status' => $response->status(), + 'response' => $data, + ]); + + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Registration failed.'), + ]; + } + + return [ + 'success' => true, + 'order_id' => (string) ($data['entityid'] ?? ''), + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: registration exception', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Registration failed due to a network error.']; + } + } + + /** + * @return array{success: bool, message?: string, order_id?: string} + */ + public function transferDomain(string $domain, ?string $authCode = null, ?string $customerId = null, ?string $contactId = null): array + { + $domain = $this->normalizeFullDomain($domain); + + if ($domain === null) { + return ['success' => false, 'message' => 'Enter a valid domain name to transfer.']; + } + + [$adminContactId, $techContactId, $billingContactId] = $this->transferContactProfileForDomain( + $domain, + $contactId ?: $this->defaultContactId + ); + + $params = [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => $domain, + 'ns' => (array) config('mailinfra.nameservers'), + 'customer-id' => $customerId ?: $this->customerId, + 'reg-contact-id' => $contactId ?: $this->defaultContactId, + 'admin-contact-id' => $adminContactId, + 'tech-contact-id' => $techContactId, + 'billing-contact-id' => $billingContactId, + 'invoice-option' => 'NoInvoice', + 'purchase-privacy' => true, + 'protect-privacy' => true, + 'auto-renew' => true, + ]; + + if ($authCode !== null && trim($authCode) !== '') { + $params['auth-code'] = trim($authCode); + } + + try { + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/transfer.json', $params) + ); + + $data = $response->json(); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Transfer could not be started right now.'), + ]; + } + + return [ + 'success' => true, + 'order_id' => (string) ($data['entityid'] ?? ''), + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: transfer exception', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Transfer could not be started due to a network error.']; + } + } + + /** + * @return array{success: bool, message?: string} + */ + public function setNameservers(string $domain, array $nameservers): array + { + $domain = $this->normalizeFullDomain($domain); + $nameservers = collect($nameservers) + ->map(fn ($value) => strtolower(trim((string) $value, " \t\n\r\0\x0B."))) + ->filter() + ->unique() + ->values() + ->all(); + + if ($domain === null) { + return ['success' => false, 'message' => 'Enter a valid domain name.']; + } + + if (count($nameservers) < 2) { + return ['success' => false, 'message' => 'Enter at least two nameservers.']; + } + + try { + $orderId = $this->getOrderId($domain); + + if ($orderId === '') { + return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; + } + + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/modify-ns.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $orderId, + 'ns' => $nameservers, + ]) + ); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Could not update nameservers right now.'), + ]; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: setNameservers failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not update nameservers right now.']; + } + } + + /** + * @return array{ + * success: bool, + * message?: string, + * order_id?: string, + * auth_code?: string|null, + * auth_code_supported?: bool, + * privacy_supported?: bool, + * privacy_enabled?: bool|null, + * transfer_lock_supported?: bool, + * transfer_lock_enabled?: bool|null, + * nameservers?: array, + * status?: string|null + * } + */ + public function getRegistrarControlsSnapshot(string $domain): array + { + $domain = $this->normalizeFullDomain($domain); + + if ($domain === null) { + return ['success' => false, 'message' => 'Enter a valid domain name.']; + } + + $orderId = $this->getOrderId($domain); + if ($orderId === '') { + return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; + } + + try { + $detailsResponse = $this->rcReadClient()->get( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/details-by-name.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => $domain, + 'options' => ['OrderDetails', 'NsDetails', 'DomainStatus'], + ]) + ); + + if ($detailsResponse->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($detailsResponse, 'Could not load registrar controls right now.'), + ]; + } + + $details = $detailsResponse->json(); + if (! is_array($details)) { + $details = []; + } + + $locksResponse = $this->rcReadClient()->get($this->apiUrl.'/domains/locks.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $orderId, + ]); + + $locks = []; + if ($locksResponse->successful() && is_array($locksResponse->json())) { + $locks = $locksResponse->json(); + } + + $tld = $this->extractTld($domain); + $rawAuthCode = $this->extractSnapshotValue($details, ['domsecret', 'authcode', 'auth-code', 'eppcode', 'epp-code']); + $rawPrivacy = $this->extractSnapshotValue($details, ['isprivacyprotected', 'privacyprotected', 'privacy-protected', 'privacy']); + $rawStatus = $this->extractSnapshotValue($details, ['currentstatus', 'status', 'orderstatus']); + $rawNameservers = data_get($details, 'ns'); + + return [ + 'success' => true, + 'order_id' => $orderId, + 'auth_code' => is_scalar($rawAuthCode) ? (string) $rawAuthCode : null, + 'auth_code_supported' => $this->authCodeSupportedForTld($tld), + 'privacy_supported' => $this->privacySupportedForTld($tld), + 'privacy_enabled' => $this->toNullableBool($rawPrivacy), + 'transfer_lock_supported' => $this->transferLockSupportedForTld($tld), + 'transfer_lock_enabled' => $this->toNullableBool(data_get($locks, 'transferlock')), + 'nameservers' => $this->normalizeNameserverList($rawNameservers), + 'status' => is_scalar($rawStatus) ? (string) $rawStatus : null, + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: getRegistrarControlsSnapshot failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not load registrar controls right now.']; + } + } + + /** + * @return array{success: bool, message?: string} + */ + public function updateAuthCode(string $domain, string $authCode): array + { + $authCode = trim($authCode); + + if ($authCode === '') { + return ['success' => false, 'message' => 'Enter a new auth/EPP code.']; + } + + return $this->performDomainOrderWrite( + $domain, + '/domains/modify-auth-code.json', + ['auth-code' => $authCode], + 'Could not update the auth/EPP code right now.' + ); + } + + /** + * @return array{success: bool, message?: string} + */ + public function setTransferLock(string $domain, bool $enabled): array + { + return $this->performDomainOrderWrite( + $domain, + $enabled ? '/domains/enable-theft-protection.json' : '/domains/disable-theft-protection.json', + [], + $enabled + ? 'Could not enable registrar lock right now.' + : 'Could not disable registrar lock right now.' + ); + } + + /** + * @return array{success: bool, message?: string} + */ + public function setPrivacyProtection(string $domain, bool $enabled, string $reason = 'Managed from Ladill dashboard.'): array + { + return $this->performDomainOrderWrite( + $domain, + '/domains/modify-privacy-protection.json', + [ + 'protect-privacy' => $enabled, + 'reason' => trim($reason) !== '' ? trim($reason) : 'Managed from Ladill dashboard.', + ], + $enabled + ? 'Could not enable privacy protection right now.' + : 'Could not disable privacy protection right now.' + ); + } + + /** + * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string} + */ + public function quoteDomainRegistration(string $domain): array + { + $results = $this->checkAvailability($domain, DomainConfig::searchTlds()); + + if (! ($results['success'] ?? false)) { + return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain right now.']; + } + + $result = collect((array) ($results['results'] ?? []))->first(); + + if (! is_array($result) || ! ($result['available'] ?? false)) { + return ['success' => false, 'message' => 'This domain is no longer available for registration.']; + } + + $pricing = $this->preferredDomainPricing( + (string) ($result['domain'] ?? $domain), + 'addnewdomain', + [ + 'amount_minor' => (int) ($result['price_minor'] ?? 0), + 'currency' => (string) ($result['currency'] ?? ''), + 'label' => (string) ($result['price'] ?? ''), + ] + ); + + $amountMinor = (int) ($pricing['amount_minor'] ?? 0); + + if ($amountMinor <= 0) { + return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'registration')]; + } + + $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); + + return [ + 'success' => true, + 'domain' => (string) ($result['domain'] ?? $domain), + 'amount_minor' => $amountMinor, + 'currency' => $currency, + 'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)), + ]; + } + + /** + * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string} + */ + public function quoteDomainTransfer(string $domain): array + { + $validation = $this->validateTransfer($domain); + + if (! ($validation['success'] ?? false) || ! ($validation['transferable'] ?? false)) { + return ['success' => false, 'message' => $validation['message'] ?? 'This domain is not ready for transfer.']; + } + + $results = $this->checkAvailability($domain, DomainConfig::searchTlds()); + + if (! ($results['success'] ?? false)) { + return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain transfer right now.']; + } + + $result = collect((array) ($results['results'] ?? []))->first(); + + if (! is_array($result) || ! ($result['transferable'] ?? false)) { + return ['success' => false, 'message' => 'This domain is not ready for transfer.']; + } + + $pricing = $this->preferredDomainPricing( + (string) ($result['domain'] ?? $domain), + 'addtransferdomain', + [ + 'amount_minor' => (int) ($result['transfer_price_minor'] ?? 0), + 'currency' => (string) ($result['currency'] ?? ''), + 'label' => (string) ($result['transfer_price'] ?? ''), + ] + ); + + $amountMinor = (int) ($pricing['amount_minor'] ?? 0); + + if ($amountMinor <= 0) { + return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'transfer')]; + } + + $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); + + return [ + 'success' => true, + 'domain' => (string) ($result['domain'] ?? $domain), + 'amount_minor' => $amountMinor, + 'currency' => $currency, + 'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)), + ]; + } + + /** + * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, rc_order_id?: string, message?: string} + */ + public function quoteDomainRenewal(string $domain, int $years = 1): array + { + $domain = strtolower(trim($domain)); + $years = max(1, min(10, $years)); + $rcOrderId = $this->getOrderId($domain); + + if ($rcOrderId === '') { + return ['success' => false, 'message' => 'This domain is not registered with ResellerClub in our system.']; + } + + $pricing = $this->preferredDomainPricing($domain, 'renewdomain', []); + + $amountMinor = (int) ($pricing['amount_minor'] ?? 0) * $years; + + if ($amountMinor <= 0) { + return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'renewal')]; + } + + $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); + + return [ + 'success' => true, + 'domain' => $domain, + 'rc_order_id' => $rcOrderId, + 'amount_minor' => $amountMinor, + 'currency' => $currency, + 'price_label' => $this->formatMinorLabel($amountMinor, $currency), + ]; + } + + /** + * @return array{success: bool, order_id?: string, message?: string} + */ + public function renewDomain(string $domain, int $years = 1, ?string $customerId = null): array + { + $domain = strtolower(trim($domain)); + $years = max(1, min(10, $years)); + $rcOrderId = $this->getOrderId($domain); + + if ($rcOrderId === '') { + return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; + } + + try { + $params = [ + 'years' => $years, + 'invoice-option' => 'NoInvoice', + ]; + + if ($customerId !== null && $customerId !== '') { + $params['customer-id'] = $customerId; + } + + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/renew.json', array_merge([ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $rcOrderId, + ], $params)) + ); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, 'Domain renewal failed.'), + ]; + } + + return [ + 'success' => true, + 'order_id' => $rcOrderId, + ]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: renewDomain failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Domain renewal failed due to a network error.']; + } + } + + /** + * Get the order ID for a domain from the registrar. + */ + private function getOrderId(string $domain): string + { + $domain = strtolower(trim($domain)); + + if ($domain === '') { + return ''; + } + + return Cache::remember('rc:order-id:'.$domain, self::ORDER_ID_CACHE_TTL_SECONDS, function () use ($domain) { + try { + $response = $this->rcReadClient()->get($this->apiUrl.'/domains/orderid.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'domain-name' => $domain, + ]); + + if ($response->failed()) { + return ''; + } + + $json = $response->json(); + + if (is_int($json) || is_float($json) || (is_string($json) && is_numeric($json))) { + return (string) $json; + } + + $body = trim(strip_tags((string) $response->body())); + + return is_numeric($body) ? $body : ''; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: getOrderId failed', [ + 'domain' => $domain, + 'error' => $e->getMessage(), + ]); + + return ''; + } + }); + } + + /** + * @param array $parameters + * @return array{success: bool, message?: string} + */ + private function performDomainOrderWrite(string $domain, string $path, array $parameters, string $fallback): array + { + $domain = $this->normalizeFullDomain($domain); + + if ($domain === null) { + return ['success' => false, 'message' => 'Enter a valid domain name.']; + } + + try { + $orderId = $this->getOrderId($domain); + if ($orderId === '') { + return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; + } + + $response = $this->rcWriteClient()->post( + $this->buildRepeatedQueryUrl($this->apiUrl.$path, array_merge([ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $orderId, + ], $parameters)) + ); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractResponseMessage($response, $fallback), + ]; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('DomainRegistrarService: performDomainOrderWrite failed', [ + 'domain' => $domain, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => $fallback]; + } + } + + private function configuredApiUrl(): string + { + $value = trim((string) config('mailinfra.registrar_api_url')); + + return $value !== '' ? $value : self::DEFAULT_API_URL; + } + + private function configuredDomainCheckApiUrl(): string + { + $value = trim((string) config('mailinfra.registrar_domaincheck_api_url')); + + return $value !== '' ? $value : self::DEFAULT_DOMAINCHECK_API_URL; + } + + private function rcReadClient(): PendingRequest + { + return Http::acceptJson() + ->connectTimeout(3) + ->timeout(8) + ->retry(2, 250, throw: false); + } + + private function rcWriteClient(): PendingRequest + { + return Http::acceptJson() + ->connectTimeout(5) + ->timeout(25) + ->retry(1, 300, throw: false); + } + + /** + * RC expects repeated keys for array values instead of PHP-style key[0]=value encoding. + * + * @param array $parameters + */ + private function buildRepeatedQueryUrl(string $baseUrl, array $parameters): string + { + $segments = []; + + foreach ($parameters as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + if ($item === null || $item === '') { + continue; + } + + $segments[] = rawurlencode((string) $key).'='.rawurlencode((string) $item); + } + + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $normalized = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + $segments[] = rawurlencode((string) $key).'='.rawurlencode($normalized); + } + + if ($segments === []) { + return $baseUrl; + } + + return $baseUrl.(Str::contains($baseUrl, '?') ? '&' : '?').implode('&', $segments); + } + + private function normalizeSearchQuery(string $query): string + { + $query = strtolower(trim($query)); + $query = preg_replace('#^https?://#', '', $query) ?? $query; + $query = preg_replace('#/.*$#', '', $query) ?? $query; + $query = preg_replace('/^www\./', '', $query) ?? $query; + + return trim($query, " \t\n\r\0\x0B."); + } + + private function normalizeFullDomain(string $domain): ?string + { + $domain = $this->normalizeSearchQuery($domain); + + if (! str_contains($domain, '.')) { + return null; + } + + return preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]{2,63})+$/', $domain) === 1 + ? $domain + : null; + } + + /** + * @param array $payload + * @param array $keys + */ + private function extractSnapshotValue(array $payload, array $keys): mixed + { + foreach ($keys as $key) { + $value = data_get($payload, $key); + if ($value !== null && $value !== '') { + return $value; + } + } + + return null; + } + + private function toNullableBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return (bool) $value; + } + + if (is_string($value)) { + $value = strtolower(trim($value)); + if (in_array($value, ['true', '1', 'yes', 'enabled', 'active'], true)) { + return true; + } + + if (in_array($value, ['false', '0', 'no', 'disabled', 'inactive'], true)) { + return false; + } + } + + return null; + } + + /** + * @return array + */ + private function normalizeNameserverList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return collect($value) + ->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B."))) + ->filter() + ->values() + ->all(); + } + + private function authCodeSupportedForTld(string $tld): bool + { + return ! $this->tldMatchesAny($tld, ['es', 'ru', 'uk', 'web.in', 'at']); + } + + private function transferLockSupportedForTld(string $tld): bool + { + return ! $this->tldMatchesAny($tld, ['au', 'de', 'es', 'eu', 'fr', 'nl', 'ru', 'uk', 'web.in']); + } + + private function privacySupportedForTld(string $tld): bool + { + return ! $this->tldMatchesAny($tld, [ + 'asia', + 'au', + 'ca', + 'cn', + 'org.co', + 'mil.co', + 'gov.co', + 'edu.co', + 'de', + 'es', + 'eu', + 'in', + 'nl', + 'nz', + 'pro', + 'ru', + 'sx', + 'tel', + 'uk', + 'us', + 'mx', + 'at', + 'web.in', + ]); + } + + /** + * @param array $blockedTlds + */ + private function tldMatchesAny(string $tld, array $blockedTlds): bool + { + foreach ($blockedTlds as $blockedTld) { + $blockedTld = strtolower(trim($blockedTld)); + + if ($blockedTld === '') { + continue; + } + + if ($tld === $blockedTld || str_ends_with($tld, '.'.$blockedTld)) { + return true; + } + } + + return false; + } + + /** + * @param array $defaultTlds + * @return array{valid: bool, keyword?: string, tlds?: array, exact?: bool, message?: string} + */ + private function parseSearchQuery(string $query, array $defaultTlds): array + { + $query = $this->normalizeSearchQuery($query); + + if ($query === '') { + return ['valid' => false, 'message' => 'Enter a domain name to search.']; + } + + if (str_contains($query, '.')) { + $domain = $this->normalizeFullDomain($query); + + if ($domain === null) { + return ['valid' => false, 'message' => 'Enter a valid domain such as ladill.com.']; + } + + $parts = explode('.', $domain); + $keyword = array_shift($parts); + $tld = implode('.', $parts); + + return [ + 'valid' => true, + 'keyword' => $keyword, + 'tlds' => [$tld], + 'exact' => true, + ]; + } + + if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $query) !== 1) { + return ['valid' => false, 'message' => 'Enter a valid name such as ladill or ladill.com.']; + } + + return [ + 'valid' => true, + 'keyword' => $query, + 'tlds' => array_values(array_unique(array_map( + fn ($tld) => ltrim(strtolower((string) $tld), '.'), + array_filter($defaultTlds) + ))), + 'exact' => false, + ]; + } + + /** + * @param array $tlds + * @param array $data + * @param array $pricingMap + * @return array + */ + private function mapAvailabilityResults(string $keyword, array $tlds, array $data, array $pricingMap): array + { + $results = []; + + foreach ($tlds as $tld) { + $normalizedTld = ltrim(strtolower($tld), '.'); + $domain = $keyword.'.'.$normalizedTld; + $payload = is_array($data[$domain] ?? null) ? $data[$domain] : []; + $status = strtolower((string) ($payload['status'] ?? 'unknown')); + + $inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create'); + $inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer'); + + $pricedCreateQuote = $status === 'available' + ? $this->resolveAvailabilityPriceQuote( + $domain, + 'addnewdomain', + $inlineCreateQuote, + $pricingMap[$normalizedTld]['addnewdomain'] ?? [] + ) + : []; + + $pricedTransferQuote = $status === 'regthroughothers' + ? $this->resolveAvailabilityPriceQuote( + $domain, + 'addtransferdomain', + $inlineTransferQuote, + $pricingMap[$normalizedTld]['addtransferdomain'] ?? [] + ) + : []; + + $results[] = [ + 'domain' => $domain, + 'available' => $status === 'available', + 'transferable' => $status === 'regthroughothers', + 'status' => $status, + 'status_label' => $this->availabilityStatusLabel($status), + 'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null), + 'price' => $pricedCreateQuote['label'] ?? null, + 'transfer_price' => $pricedTransferQuote['label'] ?? null, + 'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null, + 'price_minor' => $pricedCreateQuote['amount_minor'] ?? null, + 'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null, + ]; + } + + return $results; + } + + /** + * @param array $data + * @param array $pricingMap + * @return array + */ + private function mapSuggestedDomains(array $data, ?string $excludeDomain = null, array $pricingMap = []): array + { + $results = []; + $excludeDomain = $excludeDomain !== null ? strtolower(trim($excludeDomain)) : null; + + foreach ($data as $domain => $payload) { + if (! is_string($domain) || ! is_array($payload)) { + continue; + } + + $normalizedDomain = strtolower(trim($domain)); + + if ($normalizedDomain === '' || $normalizedDomain === $excludeDomain || isset($results[$normalizedDomain])) { + continue; + } + + $status = strtolower((string) ($payload['status'] ?? 'unknown')); + $tld = $this->extractTld($normalizedDomain); + + $inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create'); + $inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer'); + + $pricedCreateQuote = $status === 'available' + ? $this->resolveAvailabilityPriceQuote( + $normalizedDomain, + 'addnewdomain', + $inlineCreateQuote, + $pricingMap[$tld]['addnewdomain'] ?? [] + ) + : []; + + $pricedTransferQuote = $status === 'regthroughothers' + ? $this->resolveAvailabilityPriceQuote( + $normalizedDomain, + 'addtransferdomain', + $inlineTransferQuote, + $pricingMap[$tld]['addtransferdomain'] ?? [] + ) + : []; + + $results[$normalizedDomain] = [ + 'domain' => $normalizedDomain, + 'available' => $status === 'available', + 'transferable' => $status === 'regthroughothers', + 'status' => $status, + 'status_label' => $this->availabilityStatusLabel($status), + 'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null), + 'price' => $pricedCreateQuote['label'] ?? null, + 'transfer_price' => $pricedTransferQuote['label'] ?? null, + 'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null, + 'price_minor' => $pricedCreateQuote['amount_minor'] ?? null, + 'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null, + ]; + } + + return array_values($results); + } + + private function availabilityStatusLabel(string $status): string + { + return match ($status) { + 'available' => 'Available', + 'regthroughothers' => 'Taken', + 'regthroughus' => 'Already registered', + 'unknown' => 'Check again shortly', + default => 'Unavailable', + }; + } + + /** + * @param array $payload + * @return array{label?: string, amount_minor?: int, currency?: string} + */ + private function extractAvailabilityCostHashQuote(array $payload, string $operation): array + { + $costHash = $payload['costhash'] ?? $payload['costHash'] ?? null; + + if (! is_array($costHash)) { + return []; + } + + $value = $costHash[$operation] ?? null; + + if (! is_numeric($value)) { + return []; + } + + $symbol = trim((string) ($costHash['currency-symbol'] ?? $costHash['currencySymbol'] ?? '')); + $currency = strtoupper(trim((string) ($costHash['currency-code'] ?? $costHash['currencyCode'] ?? ''))); + + if ($currency === '') { + $currency = $this->resolveCurrencyCode($symbol); + } + + if ($currency === '') { + $currency = $this->sellingCurrency; + } + + $amountMinor = (int) round(((float) $value) * 100); + + return [ + 'amount_minor' => $amountMinor, + 'currency' => $currency, + 'label' => $this->formatMinorLabel($amountMinor, $currency), + ]; + } + + /** + * Customer-facing RC domain pricing source: cached customer-price table. + * + * @return array + */ + private function getCustomerPriceTable(): array + { + return Cache::remember(self::CUSTOMER_PRICE_TABLE_CACHE_KEY, self::CUSTOMER_PRICE_TABLE_TTL_SECONDS, function () { + if ((bool) Cache::get(self::API_BLOCK_CACHE_KEY, false)) { + return []; + } + + try { + $response = $this->rcReadClient()->get($this->apiUrl.'/products/customer-price.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ]); + + if ($response->failed()) { + $blocked = $this->isRegistrarSecurityBlock($response); + + Log::warning('DomainRegistrarService: customer-price failed', [ + 'status' => $response->status(), + 'blocked' => $blocked, + 'body' => Str::limit($response->body(), 500), + ]); + + if ($blocked) { + Cache::put(self::API_BLOCK_CACHE_KEY, true, self::API_BLOCK_TTL_SECONDS); + } + + return []; + } + + $data = $response->json(); + + return is_array($data) ? $data : []; + } catch (\Throwable $e) { + Log::warning('DomainRegistrarService: customer-price exception', [ + 'error' => $e->getMessage(), + ]); + + return []; + } + }); + } + + /** + * @return array + */ + private function getProductDetailsCatalog(): array + { + return Cache::remember( + self::PRODUCT_DETAILS_CACHE_KEY, + now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()), + function (): array { + try { + $response = $this->rcReadClient()->get($this->apiUrl.'/products/details.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ]); + + if ($response->failed()) { + Log::warning('DomainRegistrarService: product-details failed', [ + 'status' => $response->status(), + 'body' => Str::limit($response->body(), 500), + ]); + + return []; + } + + $data = $response->json(); + + return is_array($data) ? $data : []; + } catch (\Throwable $e) { + Log::warning('DomainRegistrarService: product-details exception', [ + 'error' => $e->getMessage(), + ]); + + return []; + } + } + ); + } + + /** + * @param array $tlds + * @return array + */ + private function preloadPricingForTlds(array $tlds): array + { + $map = []; + $normalizedTlds = array_values(array_unique(array_map( + fn ($tld) => ltrim(strtolower((string) $tld), '.'), + array_filter($tlds) + ))); + + if ($normalizedTlds === []) { + return $map; + } + + $table = $this->getCustomerPriceTable(); + + foreach ($normalizedTlds as $tld) { + $map[$tld] = [ + 'addnewdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addnewdomain', 1), + 'addtransferdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addtransferdomain', 1), + ]; + } + + return $map; + } + + /** + * @param array $table + * @return array{amount_minor?: int, currency?: string, label?: string} + */ + private function extractTldPricingFromCustomerTable(array $table, string $tld, string $actionType, int $years = 1): array + { + if ($tld === '' || $table === []) { + return []; + } + + $productKey = $this->resolveProductKeyForTld($tld, $table); + + if ($productKey === null) { + return []; + } + + $entry = $table[$productKey] ?? null; + + if (! is_array($entry)) { + return []; + } + + $actionData = $entry[$actionType] ?? null; + + if (! is_array($actionData)) { + return []; + } + + $price = $actionData[(string) $years] ?? null; + + if (! is_numeric($price)) { + return []; + } + + $amountMinor = (int) round(((float) $price) * 100); + + return [ + 'amount_minor' => $amountMinor, + 'currency' => $this->sellingCurrency, + 'label' => $this->formatMinorLabel($amountMinor, $this->sellingCurrency), + ]; + } + + /** + * @param array $table + */ + private function resolveProductKeyForTld(string $tld, array $table): ?string + { + $normalizedTld = ltrim(strtolower($tld), '.'); + $configured = trim((string) ($this->tldProductKeys[$normalizedTld] ?? '')); + + if ($normalizedTld === '') { + return null; + } + + if ($configured !== '' && isset($table[$configured])) { + return $configured; + } + + $resolved = Cache::remember( + 'rc:domain-product-key:'.$normalizedTld, + now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()), + fn (): string => $this->fetchProductKeyFromApi($normalizedTld, $table) ?? self::PRODUCT_KEY_CACHE_MISS + ); + + return $resolved !== self::PRODUCT_KEY_CACHE_MISS ? $resolved : null; + } + + /** + * @param array $table + */ + private function fetchProductKeyFromApi(string $tld, array $table): ?string + { + foreach ($this->productDetailsRows($this->getProductDetailsCatalog()) as $row) { + if (! $this->detailsRowMatchesTld($row['payload'], $tld)) { + continue; + } + + $productKey = $row['product_key']; + + if ($productKey !== '' && isset($table[$productKey])) { + return $productKey; + } + } + + return null; + } + + /** + * @param array $details + * @return list}> + */ + private function productDetailsRows(array $details): array + { + $rows = []; + $this->collectProductDetailsRows($details, $rows); + + return $rows; + } + + /** + * @param array $node + * @param array}> $rows + * @param array $context + */ + private function collectProductDetailsRows(array $node, array &$rows, array $context = []): void + { + foreach ($node as $key => $value) { + if (! is_array($value)) { + continue; + } + + $childContext = $this->mergeProductDetailsContext($context, is_string($key) ? $key : null, $value); + $payload = array_merge($childContext, $value); + $productKey = $this->extractProductKeyFromDetailsRow($payload, is_string($key) ? $key : null); + + if ($productKey !== null && $this->looksLikeProductDetailsRow($payload)) { + $rows[] = [ + 'product_key' => $productKey, + 'payload' => $payload, + ]; + } + + $this->collectProductDetailsRows($value, $rows, $childContext); + } + } + + /** + * @param array $context + * @param array $node + * @return array + */ + private function mergeProductDetailsContext(array $context, ?string $key, array $node): array + { + $merged = $context; + + foreach ([ + 'category' => ['category', 'categoryname'], + 'group' => ['group', 'groupname', 'productgroup', 'type'], + ] as $target => $fields) { + foreach ($fields as $field) { + $value = trim((string) ($node[$field] ?? '')); + + if ($value !== '') { + $merged[$target] = strtolower($value); + + continue 2; + } + } + } + + $normalizedKey = strtolower(trim((string) $key)); + + if (($merged['category'] ?? '') === '' && $normalizedKey === 'domain') { + $merged['category'] = 'domain'; + } + + if (($merged['group'] ?? '') === '' && $normalizedKey === 'tld') { + $merged['group'] = 'tld'; + } + + return $merged; + } + + /** + * @param array $row + */ + private function looksLikeProductDetailsRow(array $row): bool + { + foreach ([ + 'category', + 'categoryname', + 'group', + 'groupname', + 'productgroup', + 'type', + 'productkey', + 'product-key', + 'product_key', + 'name', + 'productname', + 'product_name', + 'product', + 'slug', + 'tld', + ] as $field) { + if (array_key_exists($field, $row)) { + return true; + } + } + + return false; + } + + /** + * @param array $row + */ + private function extractProductKeyFromDetailsRow(array $row, ?string $fallbackKey = null): ?string + { + foreach (['productkey', 'product-key', 'product_key', 'key'] as $field) { + $candidate = trim((string) ($row[$field] ?? '')); + + if ($candidate !== '') { + return $candidate; + } + } + + $candidate = trim((string) $fallbackKey); + + return $candidate !== '' ? $candidate : null; + } + + /** + * @param array $row + */ + private function detailsRowMatchesTld(array $row, string $tld): bool + { + return $this->detailsRowIsDomainTld($row) + && $this->detailsRowContainsTld($row, $tld); + } + + /** + * @param array $row + */ + private function detailsRowIsDomainTld(array $row): bool + { + $category = $this->detailsRowFieldContains($row, ['category', 'categoryname'], 'domain'); + $group = $this->detailsRowFieldContains($row, ['group', 'groupname', 'productgroup', 'type'], 'tld'); + + return $category && $group; + } + + /** + * @param array $row + * @param list $fields + */ + private function detailsRowFieldContains(array $row, array $fields, string $needle): bool + { + foreach ($fields as $field) { + $value = strtolower(trim((string) ($row[$field] ?? ''))); + + if ($value !== '' && str_contains($value, $needle)) { + return true; + } + } + + return false; + } + + /** + * @param array $row + */ + private function detailsRowContainsTld(array $row, string $tld): bool + { + $pattern = '/(^|[^a-z0-9])\.?'.preg_quote($tld, '/').'($|[^a-z0-9])/i'; + + foreach (['tld', 'name', 'productname', 'product_name', 'product', 'description', 'slug'] as $field) { + $value = trim((string) ($row[$field] ?? '')); + + if ($value === '') { + continue; + } + + if (ltrim(strtolower($value), '.') === $tld || preg_match($pattern, strtolower($value)) === 1) { + return true; + } + } + + return false; + } + + /** + * @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote + * @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool} + */ + private function preferredDomainPricing(string $domain, string $actionType, array $inlineQuote = []): array + { + $tableQuote = $this->extractTldPricingFromCustomerTable( + $this->getCustomerPriceTable(), + $this->extractTld($domain), + $actionType, + 1 + ); + + if (($tableQuote['amount_minor'] ?? 0) > 0) { + return $tableQuote; + } + + return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : []; + } + + /** + * @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote + * @param array{amount_minor?: int, currency?: string, label?: string} $prefetchedQuote + * @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool} + */ + private function resolveAvailabilityPriceQuote( + string $domain, + string $actionType, + array $inlineQuote = [], + array $prefetchedQuote = [] + ): array { + if (($prefetchedQuote['amount_minor'] ?? 0) > 0) { + return $prefetchedQuote; + } + + return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : []; + } + + private function formatMinorLabel(int $amountMinor, string $currency): string + { + return strtoupper($currency).' '.number_format($amountMinor / 100, 2); + } + + /** + * @param array{blocked?: bool} $pricing + */ + private function pricingUnavailableMessage(array $pricing, string $context): string + { + if ((bool) ($pricing['blocked'] ?? false)) { + return $context === 'transfer' + ? 'Live transfer pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.' + : 'Live domain pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.'; + } + + return $context === 'transfer' + ? 'Transfer pricing is temporarily unavailable. Please try again shortly.' + : 'Domain pricing is temporarily unavailable. Please try again shortly.'; + } + + private function isRegistrarSecurityBlock(Response $response): bool + { + if (! in_array($response->status(), [403, 429, 503], true)) { + return false; + } + + $body = strtolower((string) $response->body()); + + return str_contains($body, 'cloudflare') + || str_contains($body, 'attention required') + || str_contains($body, 'you have been blocked') + || str_contains($body, 'security check'); + } + + /** + * Extract the TLD from a full domain name. + */ + private function extractTld(string $domain): string + { + $domain = strtolower(trim($domain)); + $dot = strpos($domain, '.'); + + return $dot !== false ? substr($domain, $dot + 1) : ''; + } + + private function booleanFromResponse(Response $response): bool + { + $json = $response->json(); + + if (is_bool($json)) { + return $json; + } + + if (is_string($json)) { + return filter_var($json, FILTER_VALIDATE_BOOLEAN); + } + + $body = strtolower(trim(strip_tags((string) $response->body()))); + + return in_array($body, ['1', 'true', 'yes'], true); + } + + private function extractResponseMessage(Response $response, string $fallback): string + { + $json = $response->json(); + + if (is_array($json)) { + foreach (['message', 'error', 'description'] as $key) { + $value = trim((string) ($json[$key] ?? '')); + + if ($value !== '') { + return $value; + } + } + } + + $body = trim(strip_tags((string) $response->body())); + + if ($body === '' || str_contains(strtolower((string) $response->body()), ' 'GHS', + '₦', 'NGN' => 'NGN', + 'KSh', 'KES' => 'KES', + 'R', 'ZAR' => 'ZAR', + '$', 'USD' => 'USD', + '€', 'EUR' => 'EUR', + '£', 'GBP' => 'GBP', + default => '', + }; + } + + /** + * Certain TLDs require -1 for some contact slots during transfer. + * + * @return array{0: string, 1: string, 2: string} + */ + private function transferContactProfileForDomain(string $domain, string $defaultContactId): array + { + $tld = '.'.strtolower($this->extractTld($domain)); + + $admin = $defaultContactId; + $tech = $defaultContactId; + $billing = $defaultContactId; + + if (in_array($tld, ['.eu', '.ru', '.uk'], true)) { + $admin = '-1'; + } + + if (in_array($tld, ['.ru', '.uk'], true)) { + $tech = '-1'; + } + + if (in_array($tld, ['.at', '.berlin', '.ca', '.nl', '.nz', '.ru', '.uk'], true)) { + $billing = '-1'; + } + + return [$admin, $tech, $billing]; + } +} diff --git a/app/Services/Domain/DomainRegistryService.php b/app/Services/Domain/DomainRegistryService.php new file mode 100644 index 0000000..804a5d2 --- /dev/null +++ b/app/Services/Domain/DomainRegistryService.php @@ -0,0 +1,137 @@ +where('host', $host)->first(); + + $verificationMeta = array_merge( + (array) ($existing?->verification_meta ?? []), + $meta, + array_filter([ + 'registrar' => $registrar, + 'registrar_order_id' => $registrarOrderId, + 'registered_at' => $verified ? now()->toIso8601String() : null, + ]), + ); + + $attributes = [ + 'user_id' => $existing?->user_id ?: $userId, + 'host' => $host, + 'type' => $existing?->type ?: 'custom', + 'source' => $this->resolveSource($existing), + 'onboarding_mode' => $existing?->onboarding_mode ?: Domain::MODE_RESELLER_AUTO, + 'status' => $verified ? 'verified' : ($existing?->status ?: 'pending'), + 'onboarding_state' => $verified + ? Domain::STATE_ACTIVE + : ($existing?->onboarding_state ?: Domain::STATE_PENDING_NS), + 'registrar' => $registrar ?: $existing?->registrar, + 'registrar_order_id' => $registrarOrderId ?: $existing?->registrar_order_id, + 'verified_at' => $verified ? ($existing?->verified_at ?: now()) : $existing?->verified_at, + 'verification_meta' => $verificationMeta, + ]; + + if ($existing) { + $existing->update($attributes); + + return $existing->fresh(); + } + + return Domain::create($attributes); + } + + public function recordFromDomainOrder(DomainOrder $order, bool $verified = true): Domain + { + if (! $order->user_id) { + throw new \InvalidArgumentException('Domain order is missing user_id.'); + } + + return $this->recordPurchasedDomain( + $order->user_id, + $order->domain_name, + (string) ($order->registrar ?: DomainOrder::REGISTRAR_DYNADOT), + $order->registrar_order_id ? (string) $order->registrar_order_id : null, + $verified, + [ + 'domain_order_id' => $order->id, + 'order_type' => $order->order_type, + ], + ); + } + + public function recordFromRcServiceOrder(RcServiceOrder $order, bool $verified = false): ?Domain + { + if (! $order->user_id || ! filled($order->domain_name)) { + return null; + } + + if (! in_array($order->order_type, [ + RcServiceOrder::TYPE_DOMAIN_REGISTRATION, + RcServiceOrder::TYPE_DOMAIN_TRANSFER, + ], true)) { + return null; + } + + return $this->recordPurchasedDomain( + $order->user_id, + $order->domain_name, + DomainOrder::REGISTRAR_RESELLERCLUB, + $order->rc_order_id ? (string) $order->rc_order_id : null, + $verified, + [ + 'rc_service_order_id' => $order->id, + 'order_type' => $order->order_type, + ], + ); + } + + public function markVerified(string $host): ?Domain + { + $domain = Domain::query()->where('host', strtolower(trim($host)))->first(); + + if (! $domain) { + return null; + } + + $domain->update([ + 'status' => 'verified', + 'onboarding_state' => Domain::STATE_ACTIVE, + 'verified_at' => $domain->verified_at ?: now(), + ]); + + return $domain->fresh(); + } + + private function resolveSource(?Domain $existing): string + { + if (! $existing) { + return 'purchased'; + } + + $source = (string) ($existing->source ?: ''); + + if (in_array($source, ['website', 'hosting', 'email', 'hosting_email', 'website_email'], true)) { + return $source; + } + + return 'purchased'; + } +} diff --git a/app/Services/Domain/DomainVerificationService.php b/app/Services/Domain/DomainVerificationService.php new file mode 100644 index 0000000..ab9e386 --- /dev/null +++ b/app/Services/Domain/DomainVerificationService.php @@ -0,0 +1,519 @@ +refresh(); + $statusBefore = (string) $domain->status; + $stateBefore = (string) ($domain->onboarding_state ?: Domain::STATE_PENDING_NS); + + $domain->update([ + 'status' => 'verifying', + 'onboarding_state' => Domain::STATE_VERIFYING_NS, + 'ns_checked_at' => now(), + ]); + + if ((string) $domain->onboarding_mode === Domain::MODE_MANUAL_DNS) { + return $this->verifyManualDns($domain->fresh(), $statusBefore, $stateBefore); + } + + return $this->verifyNameserverTakeover($domain->fresh(), $statusBefore, $stateBefore); + } + + public function prepareNameserverZone(Domain $domain): void + { + $domain = $domain->fresh(); + $this->ensureDkimKeys($domain); + $this->dispatchProvisioningChain($domain->id); + } + + public function prepareManualDnsPack(Domain $domain): void + { + $domain = $domain->fresh(); + [$selector, $publicKey] = $this->ensureDkimKeys($domain); + $pack = $this->blueprints->manualDnsPack($domain, $selector, $publicKey); + $this->syncDnsRecords($domain, $pack, 'required_manual'); + } + + private function verifyNameserverTakeover(Domain $domain, string $statusBefore, string $stateBefore): bool + { + $expected = $this->normalizeNameservers((array) ($domain->ns_expected ?? $this->blueprints->expectedNameservers())); + $observed = $this->lookupNameservers($domain->host); + + $domain->update([ + 'ns_expected' => $expected, + 'ns_observed' => $observed, + ]); + + if (! $this->nameserversMatch($expected, $observed)) { + $domain->update([ + 'status' => 'pending', + 'onboarding_state' => Domain::STATE_PENDING_NS, + ]); + $domain = $domain->fresh(); + $this->logCheck($domain, [ + 'check_type' => 'ns_takeover', + 'result' => 'pending', + 'expected_nameservers' => $expected, + 'observed_nameservers' => $observed, + 'has_authoritative_answer' => null, + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'Nameservers do not match expected Ladill nameservers yet.', + ]); + + return false; + } + + $hasAuthoritativeAnswer = $this->zoneHasAuthoritativeAnswer($domain->host); + if (! $hasAuthoritativeAnswer) { + ProvisionDomainZoneJob::dispatch($domain->id); + + $domain->update([ + 'status' => 'verifying', + 'onboarding_state' => Domain::STATE_VERIFYING_NS, + ]); + $domain = $domain->fresh(); + $this->logCheck($domain, [ + 'check_type' => 'ns_takeover', + 'result' => 'pending', + 'expected_nameservers' => $expected, + 'observed_nameservers' => $observed, + 'has_authoritative_answer' => false, + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'Nameservers match, but authoritative zone answer is not ready yet.', + ]); + + return false; + } + + return DB::transaction(function () use ($domain, $statusBefore, $stateBefore, $expected, $observed, $hasAuthoritativeAnswer): bool { + $domain->refresh(); + $domain->update([ + 'status' => 'verifying', + 'onboarding_state' => Domain::STATE_CONNECTED, + 'connected_at' => $domain->connected_at ?? now(), + 'dns_mode' => 'managed', + ]); + + [$selector, $publicKey] = $this->ensureDkimKeys($domain); + $records = $this->blueprints->managedRecords($domain, $selector, $publicKey); + $this->syncDnsRecords($domain, $records, 'managed'); + + $domain->update([ + 'onboarding_state' => Domain::STATE_MAIL_READY, + 'mail_ready_at' => $domain->mail_ready_at ?? now(), + ]); + + $domain->update([ + 'status' => 'verified', + 'verified_at' => $domain->verified_at ?? now(), + 'onboarding_state' => Domain::STATE_ACTIVE, + 'active_at' => $domain->active_at ?? now(), + ]); + $domain->refresh(); + $this->logCheck($domain, [ + 'check_type' => 'ns_takeover', + 'result' => 'success', + 'expected_nameservers' => $expected, + 'observed_nameservers' => $observed, + 'has_authoritative_answer' => $hasAuthoritativeAnswer, + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'Nameserver takeover complete. Managed DNS and mail records are provisioned.', + ]); + $this->dispatchProvisioningChain($domain->id); + + // Notify user that domain is verified + $user = $domain->website?->user; + if ($user) { + $user->notify(new DomainVerifiedNotification($domain)); + } + + return true; + }); + } + + private function verifyManualDns(Domain $domain, string $statusBefore, string $stateBefore): bool + { + $this->prepareManualDnsPack($domain); + $domain = $domain->fresh('dnsRecords'); + + $records = $domain->dnsRecords + ->where('source', 'required_manual') + ->values(); + + if ($records->isEmpty()) { + $domain->update([ + 'status' => 'failed', + 'onboarding_state' => Domain::STATE_FAILED, + ]); + $domain = $domain->fresh(); + $this->logCheck($domain, [ + 'check_type' => 'manual_dns', + 'result' => 'failed', + 'manual_records_total' => 0, + 'manual_records_verified' => 0, + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'Manual DNS pack is empty; cannot verify domain.', + ]); + + return false; + } + + $allVerified = true; + + foreach ($records as $record) { + $matched = $this->recordExistsInPublicDns($domain->host, $record); + + $record->update([ + 'status' => $matched ? 'verified' : 'pending', + 'last_checked_at' => now(), + ]); + + if (! $matched) { + $allVerified = false; + } + } + + if (! $allVerified) { + $domain->update([ + 'status' => 'pending', + 'onboarding_state' => Domain::STATE_VERIFYING_NS, + 'ns_checked_at' => now(), + ]); + $domain = $domain->fresh(); + $this->logCheck($domain, [ + 'check_type' => 'manual_dns', + 'result' => 'pending', + 'manual_records_total' => $records->count(), + 'manual_records_verified' => $records->where('status', 'verified')->count(), + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'Manual DNS records are not fully propagated yet.', + ]); + + return false; + } + + $domain->update([ + 'status' => 'verified', + 'verified_at' => $domain->verified_at ?? now(), + 'onboarding_state' => Domain::STATE_ACTIVE, + 'dns_mode' => 'manual', + 'manual_dns_verified_at' => $domain->manual_dns_verified_at ?? now(), + 'connected_at' => $domain->connected_at ?? now(), + 'mail_ready_at' => $domain->mail_ready_at ?? now(), + 'active_at' => $domain->active_at ?? now(), + 'ns_checked_at' => now(), + ]); + $domain = $domain->fresh(); + $this->logCheck($domain, [ + 'check_type' => 'manual_dns', + 'result' => 'success', + 'manual_records_total' => $records->count(), + 'manual_records_verified' => $records->where('status', 'verified')->count(), + 'status_before' => $statusBefore, + 'status_after' => (string) $domain->status, + 'state_before' => $stateBefore, + 'state_after' => (string) $domain->onboarding_state, + 'notes' => 'All required manual DNS records are verified.', + ]); + $this->dispatchProvisioningChain($domain->id); + + // Notify user that domain is verified + $user = $domain->website?->user; + if ($user) { + $user->notify(new DomainVerifiedNotification($domain)); + } + + return true; + } + + public function reprovision(Domain $domain): void + { + $this->dispatchProvisioningChain($domain->id); + } + + private function dispatchProvisioningChain(int $domainId): void + { + ProvisionDomainZoneJob::withChain([ + new MailProvisioningJob($domainId), + new SslProvisioningJob($domainId), + ])->dispatch($domainId); + } + + private function ensureDkimKeys(Domain $domain): array + { + $existing = DomainDkimKey::query() + ->where('domain_id', $domain->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + if ($existing) { + return [$existing->selector, $existing->public_key_txt]; + } + + $prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill'); + $selector = Str::lower(trim($prefix)) ?: 'ladill'; + $selector .= '1'; + + $publicKey = ''; + $privateKey = null; + + if (function_exists('openssl_pkey_new')) { + $resource = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource !== false) { + openssl_pkey_export($resource, $privateOut); + $details = openssl_pkey_get_details($resource); + $publicPem = (string) ($details['key'] ?? ''); + $publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: ''; + $privateKey = $privateOut ?: null; + } + } + + if ($publicKey === '') { + $publicKey = Str::upper(Str::random(360)); + } + + $privatePath = null; + if (is_string($privateKey) && trim($privateKey) !== '') { + $safeHost = Str::slug($domain->host, '_'); + $directory = storage_path('app/mail/dkim'); + File::ensureDirectoryExists($directory); + $privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key'; + File::put($privatePath, $privateKey); + } + + $key = DomainDkimKey::query()->create([ + 'domain_id' => $domain->id, + 'selector' => $selector, + 'public_key_txt' => $publicKey, + 'private_key_path' => $privatePath, + 'status' => 'active', + 'generated_at' => now(), + ]); + + return [$key->selector, $key->public_key_txt]; + } + + private function syncDnsRecords(Domain $domain, array $records, string $source): void + { + $keepSignatures = []; + + foreach ($records as $record) { + if (! is_array($record)) { + continue; + } + + $name = (string) ($record['name'] ?? '@'); + $type = strtoupper((string) ($record['type'] ?? 'TXT')); + $value = (string) ($record['value'] ?? ''); + $keepSignatures[] = strtolower($name.'|'.$type.'|'.$value); + + DomainDnsRecord::query()->updateOrCreate( + [ + 'domain_id' => $domain->id, + 'name' => $name, + 'type' => $type, + 'value' => $value, + ], + [ + 'ttl' => (int) ($record['ttl'] ?? 3600), + 'priority' => isset($record['priority']) ? (int) $record['priority'] : null, + 'source' => $source, + 'status' => (string) ($record['status'] ?? 'pending'), + 'notes' => $record['notes'] ?? null, + 'last_checked_at' => now(), + ] + ); + } + + DomainDnsRecord::query() + ->where('domain_id', $domain->id) + ->where('source', $source) + ->get() + ->each(function (DomainDnsRecord $record) use ($keepSignatures): void { + $signature = strtolower($record->name.'|'.$record->type.'|'.$record->value); + if (! in_array($signature, $keepSignatures, true)) { + $record->delete(); + } + }); + } + + private function lookupNameservers(string $host): array + { + if (! function_exists('dns_get_record')) { + return []; + } + + $records = @dns_get_record($host, DNS_NS); + if (! is_array($records)) { + return []; + } + + return collect($records) + ->map(fn ($record) => strtolower(trim((string) ($record['target'] ?? ''), ". \t\n\r\0\x0B"))) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function normalizeNameservers(array $nameservers): array + { + return collect($nameservers) + ->map(fn ($record) => strtolower(trim((string) $record, ". \t\n\r\0\x0B"))) + ->filter() + ->unique() + ->values() + ->all(); + } + + private function nameserversMatch(array $expected, array $observed): bool + { + $expected = $this->normalizeNameservers($expected); + $observed = $this->normalizeNameservers($observed); + + if ($expected === [] || $observed === []) { + return false; + } + + return collect($expected)->diff($observed)->isEmpty(); + } + + private function recordExistsInPublicDns(string $zoneHost, DomainDnsRecord $record): bool + { + if (! function_exists('dns_get_record')) { + return false; + } + + $name = trim((string) $record->name); + $fqdn = $name === '@' ? $zoneHost : $name.'.'.$zoneHost; + $type = strtoupper(trim((string) $record->type)); + $expectedValue = strtolower(trim((string) $record->value, ". \t\n\r\0\x0B\"")); + + $flag = match ($type) { + 'MX' => DNS_MX, + 'CNAME' => DNS_CNAME, + 'A' => DNS_A, + default => DNS_TXT, + }; + + $records = @dns_get_record($fqdn, $flag); + if (! is_array($records) || $records === []) { + return false; + } + + foreach ($records as $item) { + if ($type === 'MX') { + $value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B")); + $priority = (int) ($item['pri'] ?? 0); + $expectedPriority = (int) ($record->priority ?? 0); + if ($value === $expectedValue && $priority === $expectedPriority) { + return true; + } + continue; + } + + if ($type === 'TXT') { + $value = strtolower(trim((string) ($item['txt'] ?? ''), "\" \t\n\r\0\x0B")); + if ($value === $expectedValue) { + return true; + } + continue; + } + + if ($type === 'CNAME') { + $value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B")); + if ($value === $expectedValue) { + return true; + } + continue; + } + + if ($type === 'A') { + $value = strtolower(trim((string) ($item['ip'] ?? ''), ". \t\n\r\0\x0B")); + if ($value === $expectedValue) { + return true; + } + } + } + + return false; + } + + private function zoneHasAuthoritativeAnswer(string $host): bool + { + if (! function_exists('dns_get_record')) { + return false; + } + + $soa = @dns_get_record($host, DNS_SOA); + if (is_array($soa) && $soa !== []) { + return true; + } + + $ns = @dns_get_record($host, DNS_NS); + + return is_array($ns) && $ns !== []; + } + + private function logCheck(Domain $domain, array $payload): void + { + DomainNsCheck::query()->create([ + 'domain_id' => $domain->id, + 'check_type' => (string) ($payload['check_type'] ?? 'onboarding'), + 'result' => (string) ($payload['result'] ?? 'pending'), + 'expected_nameservers' => $payload['expected_nameservers'] ?? null, + 'observed_nameservers' => $payload['observed_nameservers'] ?? null, + 'has_authoritative_answer' => $payload['has_authoritative_answer'] ?? null, + 'manual_records_total' => (int) ($payload['manual_records_total'] ?? 0), + 'manual_records_verified' => (int) ($payload['manual_records_verified'] ?? 0), + 'status_before' => $payload['status_before'] ?? null, + 'status_after' => $payload['status_after'] ?? null, + 'state_before' => $payload['state_before'] ?? null, + 'state_after' => $payload['state_after'] ?? null, + 'notes' => $payload['notes'] ?? null, + 'checked_at' => now(), + ]); + } +} diff --git a/app/Services/EmailDomain/EmailDomainClient.php b/app/Services/EmailDomain/EmailDomainClient.php new file mode 100644 index 0000000..4ca21a8 --- /dev/null +++ b/app/Services/EmailDomain/EmailDomainClient.php @@ -0,0 +1,77 @@ +acceptJson()->timeout(10); + } + + /** All email domains for the user. */ + public function forUser(string $publicId): array + { + return $this->data($this->http()->get($this->base(), ['user' => $publicId])); + } + + /** Only verified/active domains (eligible for mailboxes). */ + public function verified(string $publicId): array + { + return $this->data($this->http()->get($this->base(), ['user' => $publicId, 'status' => 'active'])); + } + + public function find(string $publicId, int $id): ?array + { + return collect($this->forUser($publicId))->firstWhere('id', $id) ?: null; + } + + /** Full detail (incl. DNS records to publish) for one domain. */ + public function show(string $publicId, int $id): array + { + return $this->data($this->http()->get($this->base().'/'.$id, ['user' => $publicId])); + } + + /** Add an email domain. Returns its detail. */ + public function create(string $publicId, string $domain, string $method = 'dns_record'): array + { + $res = $this->http()->post($this->base(), ['user' => $publicId, 'domain' => $domain, 'verification_method' => $method]); + $res->throw(); + + return (array) $res->json('data'); + } + + /** Run verification; returns updated detail. */ + public function verify(string $publicId, int $id): array + { + $res = $this->http()->post($this->base().'/'.$id.'/verify', ['user' => $publicId]); + $res->throw(); + + return (array) $res->json('data'); + } + + public function delete(string $publicId, int $id): void + { + $this->http()->delete($this->base().'/'.$id, ['user' => $publicId])->throw(); + } + + private function data($res): array + { + $res->throw(); + + return (array) ($res->json('data') ?? $res->json()); + } +} diff --git a/app/Services/ExchangeRateService.php b/app/Services/ExchangeRateService.php new file mode 100644 index 0000000..a164259 --- /dev/null +++ b/app/Services/ExchangeRateService.php @@ -0,0 +1,52 @@ +currency->getUsdToGhsRate(); + } + + public function getEurToGhs(): float + { + return $this->currency->getEurToGhsRate(); + } + + public function fetchLiveRate(): float + { + return $this->currency->refreshRate(); + } + + public function refreshRate(): float + { + return $this->currency->refreshRate(); + } + + public function getCachedRate(): ?float + { + $info = $this->currency->getRateInfo(); + + return ($info['cached'] ?? false) + ? (float) ($info['rate'] ?? 0) + : null; + } + + public function getRateInfo(): array + { + $info = $this->currency->getRateInfo(); + + return [ + 'rate' => (float) ($info['rate'] ?? 0), + 'is_cached' => (bool) ($info['cached'] ?? false), + 'fallback_rate' => (float) ($info['fallback'] ?? config('hosting.pricing.fallback_usd_to_ghs_rate', 15.50)), + ]; + } +} diff --git a/app/Services/Hosting/AppInstaller.php b/app/Services/Hosting/AppInstaller.php new file mode 100644 index 0000000..3f7ff62 --- /dev/null +++ b/app/Services/Hosting/AppInstaller.php @@ -0,0 +1,464 @@ +nodeProvider = $nodeProvider; + } + + public function getSupportedApps(): array + { + return [ + 'wordpress' => [ + 'name' => 'WordPress', + 'description' => 'Popular blogging and CMS platform', + 'icon' => 'wordpress', + 'min_php' => '7.4', + 'recommended_php' => '8.2', + ], + 'joomla' => [ + 'name' => 'Joomla', + 'description' => 'Flexible content management system', + 'icon' => 'joomla', + 'min_php' => '7.4', + 'recommended_php' => '8.1', + ], + 'drupal' => [ + 'name' => 'Drupal', + 'description' => 'Enterprise-grade CMS', + 'icon' => 'drupal', + 'min_php' => '8.1', + 'recommended_php' => '8.2', + ], + 'opencart' => [ + 'name' => 'OpenCart', + 'description' => 'E-commerce platform', + 'icon' => 'opencart', + 'min_php' => '8.0', + 'recommended_php' => '8.1', + ], + 'magento' => [ + 'name' => 'Magento', + 'description' => 'Enterprise e-commerce platform', + 'icon' => 'magento', + 'min_php' => '8.1', + 'recommended_php' => '8.2', + 'min_ram_mb' => 2048, + ], + ]; + } + + public function getAppVersions(string $appType): array + { + return match ($appType) { + 'wordpress' => ['6.4', '6.3', '6.2'], + 'joomla' => ['5.0', '4.4', '4.3'], + 'drupal' => ['10.2', '10.1', '10.0'], + 'opencart' => ['4.0', '3.0'], + 'magento' => ['2.4.6', '2.4.5'], + default => [], + }; + } + + public function install(HostedSite $site, string $appType, array $config): AppInstallation + { + $account = $site->account; + $supportedApps = $this->getSupportedApps(); + + if (!isset($supportedApps[$appType])) { + throw new \InvalidArgumentException("Unsupported app type: {$appType}"); + } + + // Create database for the app + $dbName = $account->username . '_' . Str::random(4); + $dbUser = $account->username . '_' . Str::random(4); + $dbPassword = Str::password(16); + + $database = HostedDatabase::create([ + 'hosting_account_id' => $account->id, + 'hosted_site_id' => $site->id, + 'name' => $dbName, + 'username' => $dbUser, + 'password_encrypted' => encrypt($dbPassword), + 'type' => 'mysql', + 'status' => 'active', + ]); + + $this->nodeProvider->createDatabase($database, $dbPassword); + + // Create installation record + $installation = AppInstallation::create([ + 'hosted_site_id' => $site->id, + 'app_type' => $appType, + 'app_version' => $config['version'] ?? $this->getAppVersions($appType)[0], + 'admin_username' => $config['admin_username'] ?? 'admin', + 'admin_email' => $config['admin_email'] ?? $account->user->email, + 'status' => 'installing', + 'config' => [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'site_title' => $config['site_title'] ?? $site->domain, + ], + ]); + + try { + $result = match ($appType) { + 'wordpress' => $this->installWordPress($site, $installation, $database, $dbPassword, $config), + 'joomla' => $this->installJoomla($site, $installation, $database, $dbPassword, $config), + 'drupal' => $this->installDrupal($site, $installation, $database, $dbPassword, $config), + 'opencart' => $this->installOpenCart($site, $installation, $database, $dbPassword, $config), + 'magento' => $this->installMagento($site, $installation, $database, $dbPassword, $config), + default => throw new \InvalidArgumentException("No installer for: {$appType}"), + }; + + $installation->update([ + 'status' => 'active', + 'admin_path' => $result['admin_path'] ?? null, + 'installed_at' => now(), + ]); + + $site->update([ + 'installed_app' => $appType, + 'installed_app_version' => $installation->app_version, + ]); + + } catch (\Exception $e) { + Log::error("App installation failed", [ + 'app' => $appType, + 'site' => $site->domain, + 'error' => $e->getMessage(), + ]); + + $installation->update([ + 'status' => 'failed', + 'config' => array_merge($installation->config ?? [], ['error' => $e->getMessage()]), + ]); + + throw $e; + } + + return $installation; + } + + public function uninstall(AppInstallation $installation): bool + { + $site = $installation->site; + $account = $site->account; + $appType = $installation->app_type; + + try { + // For Node.js/Python apps, stop and remove the systemd service first + if (in_array($appType, ['nodejs', 'python'], true)) { + try { + $this->nodeProvider->removeAppService($site); + } catch (\Exception $e) { + Log::warning("Failed to remove app service during uninstall: " . $e->getMessage()); + } + + // Node.js specific cleanup + if ($appType === 'nodejs') { + $this->nodeProvider->executeCommand( + $account, + "rm -rf {$site->document_root}/node_modules {$site->document_root}/package.json {$site->document_root}/package-lock.json 2>&1 || true" + ); + } + + // Python specific cleanup + if ($appType === 'python') { + $this->nodeProvider->executeCommand( + $account, + "rm -rf {$site->document_root}/venv {$site->document_root}/__pycache__ {$site->document_root}/.venv 2>&1 || true" + ); + $this->nodeProvider->executeCommand( + $account, + "find {$site->document_root} -name '*.pyc' -delete 2>&1 || true" + ); + } + } + + // For apps with subdirectories (Drupal with web/, Laravel with public/, Magento with pub/) + // we need to clean the parent directory too + $docRoot = $site->document_root; + if (in_array($appType, ['drupal', 'laravel', 'magento'], true)) { + // Try to clean up parent directory if it's a subdirectory installation + $parentDir = dirname($docRoot); + if ($parentDir !== "/home/{$account->username}" && $parentDir !== "/home/{$account->username}/public_html") { + $this->nodeProvider->executeCommandAsRoot( + $account, + "rm -rf " . escapeshellarg($parentDir) . " 2>&1 || true" + ); + } + } + + // Remove all files from document root (use root to handle permission issues) + $escapedDocRoot = escapeshellarg($docRoot); + $this->nodeProvider->executeCommandAsRoot( + $account, + "rm -rf {$escapedDocRoot}/* {$escapedDocRoot}/.[!.]* {$escapedDocRoot}/..?* 2>&1 || true" + ); + + // Drop database and database user if exists + $dbName = $installation->config['db_name'] ?? null; + if ($dbName) { + $database = HostedDatabase::where('name', $dbName)->first(); + if ($database) { + // Drop both database and user + $dbUser = $database->username; + $this->nodeProvider->executeCommandAsRoot( + $account, + "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbUser}'@'localhost';\" 2>&1 || true" + ); + $database->delete(); + } else { + // Database record not found, try cleanup anyway + $this->nodeProvider->executeCommandAsRoot( + $account, + "mysql -e \"DROP DATABASE IF EXISTS \`{$dbName}\`; DROP USER IF EXISTS '{$dbName}'@'localhost';\" 2>&1 || true" + ); + } + } + + $installation->update(['status' => 'removed']); + $site->update(['installed_app' => null, 'installed_app_version' => null]); + + Log::info("App uninstalled successfully", [ + 'app' => $appType, + 'site' => $site->domain, + 'installation_id' => $installation->id, + ]); + + return true; + } catch (\Exception $e) { + Log::error("Failed to uninstall app: " . $e->getMessage(), [ + 'app' => $appType, + 'site' => $site->domain, + 'installation_id' => $installation->id, + ]); + throw $e; + } + } + + public function update(AppInstallation $installation, ?string $version = null): bool + { + $site = $installation->site; + $account = $site->account; + + $installation->update(['status' => 'updating']); + + try { + if ($installation->app_type === 'wordpress') { + $this->nodeProvider->executeCommand($account, + "cd {$site->document_root} && wp core update" . ($version ? " --version={$version}" : "") + ); + $this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp plugin update --all"); + $this->nodeProvider->executeCommand($account, "cd {$site->document_root} && wp theme update --all"); + } + + $installation->update([ + 'status' => 'active', + 'app_version' => $version ?? $installation->app_version, + 'last_updated_at' => now(), + ]); + + return true; + } catch (\Exception $e) { + $installation->update(['status' => 'active']); + throw $e; + } + } + + public function getStatus(AppInstallation $installation): array + { + $site = $installation->site; + $account = $site->account; + + $status = [ + 'app_type' => $installation->app_type, + 'version' => $installation->app_version, + 'status' => $installation->status, + 'installed_at' => $installation->installed_at, + ]; + + if ($installation->app_type === 'wordpress') { + $result = $this->nodeProvider->executeCommand($account, + "cd {$site->document_root} && wp core version 2>/dev/null" + ); + $status['current_version'] = trim($result['output']); + } + + return $status; + } + + public function backup(AppInstallation $installation): string + { + $site = $installation->site; + $account = $site->account; + $backupName = "{$installation->app_type}_{$site->domain}_" . date('Y-m-d_His'); + $backupPath = "/home/{$account->username}/backups/{$backupName}"; + + $this->nodeProvider->executeCommand($account, "mkdir -p /home/{$account->username}/backups"); + + // Backup files + $this->nodeProvider->executeCommand($account, + "tar -czf {$backupPath}_files.tar.gz -C {$site->document_root} ." + ); + + // Backup database + $dbName = $installation->config['db_name'] ?? null; + if ($dbName) { + $this->nodeProvider->executeCommand($account, + "mysqldump {$dbName} > {$backupPath}_db.sql" + ); + } + + return $backupPath; + } + + public function restore(AppInstallation $installation, string $backupPath): bool + { + $site = $installation->site; + $account = $site->account; + + // Restore files + $this->nodeProvider->executeCommand($account, + "rm -rf {$site->document_root}/* && tar -xzf {$backupPath}_files.tar.gz -C {$site->document_root}" + ); + + // Restore database + $dbName = $installation->config['db_name'] ?? null; + if ($dbName && file_exists("{$backupPath}_db.sql")) { + $this->nodeProvider->executeCommand($account, + "mysql {$dbName} < {$backupPath}_db.sql" + ); + } + + return true; + } + + private function installWordPress(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array + { + $account = $site->account; + $docRoot = $site->document_root; + $adminPassword = $config['admin_password'] ?? Str::password(12); + + $commands = [ + "cd {$docRoot} && wp core download --version={$installation->app_version}", + "cd {$docRoot} && wp config create --dbname={$database->name} --dbuser={$database->username} --dbpass={$dbPassword} --dbhost=localhost", + "cd {$docRoot} && wp core install --url=https://{$site->domain} --title='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin_user={$installation->admin_username} --admin_password={$adminPassword} --admin_email={$installation->admin_email}", + "cd {$docRoot} && wp rewrite structure '/%postname%/'", + "cd {$docRoot} && chmod -R 755 .", + "cd {$docRoot} && chmod -R 775 wp-content/uploads", + ]; + + foreach ($commands as $cmd) { + $result = $this->nodeProvider->executeCommand($account, $cmd); + if ($result['exit_code'] !== 0) { + throw new \RuntimeException("WordPress install failed: " . $result['output']); + } + } + + return [ + 'admin_path' => '/wp-admin', + 'admin_password' => $adminPassword, + ]; + } + + private function installJoomla(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array + { + $account = $site->account; + $docRoot = $site->document_root; + $adminPassword = $config['admin_password'] ?? Str::password(12); + + $commands = [ + "cd {$docRoot} && curl -sL https://downloads.joomla.org/cms/joomla5/{$installation->app_version}/Joomla_{$installation->app_version}-Stable-Full_Package.zip -o joomla.zip", + "cd {$docRoot} && unzip -q joomla.zip && rm joomla.zip", + "cd {$docRoot} && php installation/joomla.php install --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --admin-user={$installation->admin_username} --admin-username={$installation->admin_username} --admin-password={$adminPassword} --admin-email={$installation->admin_email} --db-type=mysqli --db-host=localhost --db-user={$database->username} --db-pass={$dbPassword} --db-name={$database->name} --db-prefix=jos_ --db-encryption=0", + "cd {$docRoot} && rm -rf installation", + "cd {$docRoot} && chmod -R 755 .", + "cd {$docRoot} && chmod 775 .", + "cd {$docRoot} && chmod -R 775 cache tmp administrator/cache administrator/logs", + "cd {$docRoot} && echo ' administrator/cache/autoload_psr4.php && echo ' cache/autoload_psr4.php", + ]; + + foreach ($commands as $cmd) { + $this->nodeProvider->executeCommand($account, $cmd); + } + + $this->nodeProvider->setWebServerGroupOwnership($account, $docRoot); + + return ['admin_path' => '/administrator']; + } + + private function installDrupal(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array + { + $account = $site->account; + $docRoot = $site->document_root; + $adminPassword = $config['admin_password'] ?? Str::password(12); + + $commands = [ + "cd {$docRoot} && composer create-project drupal/recommended-project:{$installation->app_version} . --no-interaction", + "cd {$docRoot}/web && ../vendor/bin/drush site:install standard --db-url=mysql://{$database->username}:{$dbPassword}@localhost/{$database->name} --site-name='" . addslashes($config['site_title'] ?? $site->domain) . "' --account-name={$installation->admin_username} --account-pass={$adminPassword} --account-mail={$installation->admin_email} -y", + "cd {$docRoot} && chmod -R 755 .", + ]; + + foreach ($commands as $cmd) { + $this->nodeProvider->executeCommand($account, $cmd); + } + + return ['admin_path' => '/user/login']; + } + + private function installOpenCart(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array + { + $account = $site->account; + $docRoot = $site->document_root; + $adminPassword = $config['admin_password'] ?? Str::password(12); + + $commands = [ + "cd {$docRoot} && curl -sL https://github.com/opencart/opencart/releases/download/{$installation->app_version}.0.0/opencart-{$installation->app_version}.0.0.zip -o opencart.zip", + "cd {$docRoot} && unzip -q opencart.zip && mv upload/* . && rm -rf upload opencart.zip", + "cd {$docRoot} && php install/cli_install.php install --db_hostname localhost --db_username {$database->username} --db_password {$dbPassword} --db_database {$database->name} --username {$installation->admin_username} --password {$adminPassword} --email {$installation->admin_email} --http_server https://{$site->domain}/", + "cd {$docRoot} && rm -rf install", + "cd {$docRoot} && chmod -R 755 .", + ]; + + foreach ($commands as $cmd) { + $this->nodeProvider->executeCommand($account, $cmd); + } + + return ['admin_path' => '/admin']; + } + + private function installMagento(HostedSite $site, AppInstallation $installation, HostedDatabase $database, string $dbPassword, array $config): array + { + $account = $site->account; + $docRoot = $site->document_root; + $adminPassword = $config['admin_password'] ?? Str::password(12); + + $commands = [ + "cd {$docRoot} && composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition={$installation->app_version} . --no-interaction", + "cd {$docRoot} && bin/magento setup:install --base-url=https://{$site->domain}/ --db-host=localhost --db-name={$database->name} --db-user={$database->username} --db-password={$dbPassword} --admin-firstname=Admin --admin-lastname=User --admin-email={$installation->admin_email} --admin-user={$installation->admin_username} --admin-password={$adminPassword} --language=en_US --currency=USD --timezone=UTC --use-rewrites=1", + "cd {$docRoot} && bin/magento deploy:mode:set production", + "cd {$docRoot} && chmod -R 755 .", + ]; + + foreach ($commands as $cmd) { + $this->nodeProvider->executeCommand($account, $cmd); + } + + return ['admin_path' => '/admin']; + } +} diff --git a/app/Services/Hosting/BrowserTerminalSessionManager.php b/app/Services/Hosting/BrowserTerminalSessionManager.php new file mode 100644 index 0000000..fb1e57f --- /dev/null +++ b/app/Services/Hosting/BrowserTerminalSessionManager.php @@ -0,0 +1,373 @@ +sessionDirectory($sessionId); + + if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { + throw new \RuntimeException('Unable to create browser terminal session directory.'); + } + + touch($this->eventLogPath($sessionId)); + touch($this->outputLogPath($sessionId)); + touch($this->heartbeatPath($sessionId)); + + $metadata = [ + 'session_id' => $sessionId, + 'account_id' => $account->id, + 'user_id' => $userId, + 'username' => $account->username, + 'relative_path' => $relativePath, + 'cols' => $this->normalizeColumns($cols), + 'rows' => $this->normalizeRows($rows), + 'status' => 'starting', + 'created_at' => now()->toIso8601String(), + 'last_seen_at' => now()->toIso8601String(), + 'pid' => null, + 'exit_code' => null, + 'error' => null, + ]; + + $this->writeMetadata($sessionId, $metadata); + + $workerCommand = $this->buildWorkerCommand($sessionId); + + $result = Process::timeout(10)->run(['bash', '-lc', $workerCommand]); + $pid = trim($result->output()); + + if ($result->failed() || $pid === '' || ! ctype_digit($pid)) { + $metadata['status'] = 'failed'; + $metadata['error'] = 'Unable to start the browser terminal worker.'; + $this->writeMetadata($sessionId, $metadata); + + throw new \RuntimeException($metadata['error']); + } + + $metadata['pid'] = (int) $pid; + $this->writeMetadata($sessionId, $metadata); + + $metadata = $this->waitForWorkerStartup($sessionId, $metadata); + + return $metadata; + } + + public function readOutput(HostingAccount $account, int $userId, string $sessionId, int $offset = 0): array + { + $metadata = $this->sessionMetadataFor($account, $userId, $sessionId); + $offset = max(0, $offset); + $outputPath = $this->outputLogPath($sessionId); + $size = is_file($outputPath) ? filesize($outputPath) : 0; + $chunk = ''; + + if ($size > $offset) { + $stream = fopen($outputPath, 'rb'); + + if ($stream !== false) { + fseek($stream, $offset); + $chunk = (string) stream_get_contents($stream); + fclose($stream); + } + } + + $this->touchHeartbeat($sessionId); + + return [ + 'output' => $chunk, + 'offset' => $size, + 'status' => $metadata['status'] ?? 'closed', + 'exit_code' => $metadata['exit_code'] ?? null, + 'error' => $metadata['error'] ?? null, + ]; + } + + public function appendInput(HostingAccount $account, int $userId, string $sessionId, string $input): void + { + $this->sessionMetadataFor($account, $userId, $sessionId); + $this->touchHeartbeat($sessionId); + + $this->appendEvent($sessionId, [ + 'type' => 'input', + 'data' => $input, + ]); + } + + public function resizeSession(HostingAccount $account, int $userId, string $sessionId, int $cols, int $rows): void + { + $this->sessionMetadataFor($account, $userId, $sessionId); + $this->touchHeartbeat($sessionId); + + $this->appendEvent($sessionId, [ + 'type' => 'resize', + 'cols' => $this->normalizeColumns($cols), + 'rows' => $this->normalizeRows($rows), + ]); + } + + public function closeSession(HostingAccount $account, int $userId, string $sessionId): void + { + $this->sessionMetadataFor($account, $userId, $sessionId); + $this->touchHeartbeat($sessionId); + + $this->appendEvent($sessionId, [ + 'type' => 'close', + ]); + } + + public function readWorkerMetadata(string $sessionId): array + { + $metadata = $this->readMetadata($sessionId); + + if ($metadata === null) { + throw new \RuntimeException('Browser terminal session not found.'); + } + + return $metadata; + } + + public function writeWorkerMetadata(string $sessionId, array $metadata): void + { + $this->writeMetadata($sessionId, $metadata); + } + + public function appendWorkerOutput(string $sessionId, string $output): void + { + if ($output === '') { + return; + } + + file_put_contents($this->outputLogPath($sessionId), $output, FILE_APPEND | LOCK_EX); + } + + public function readWorkerEvents(string $sessionId, int &$offset): array + { + $path = $this->eventLogPath($sessionId); + + if (! is_file($path)) { + return []; + } + + $stream = fopen($path, 'rb'); + + if ($stream === false) { + return []; + } + + fseek($stream, $offset); + $payload = (string) stream_get_contents($stream); + $offset = ftell($stream) ?: $offset; + fclose($stream); + + $events = []; + foreach (preg_split("/\r\n|\n|\r/", $payload) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $events[] = $decoded; + } + } + + return $events; + } + + public function isSessionIdle(array $metadata): bool + { + $heartbeatTime = @filemtime($this->heartbeatPath((string) ($metadata['session_id'] ?? ''))); + + if ($heartbeatTime === false) { + return false; + } + + return (time() - $heartbeatTime) >= self::IDLE_TIMEOUT_SECONDS; + } + + private function sessionMetadataFor(HostingAccount $account, int $userId, string $sessionId): array + { + $metadata = $this->readMetadata($sessionId); + + if ($metadata === null) { + throw new \RuntimeException('Browser terminal session not found.'); + } + + if (($metadata['account_id'] ?? null) !== $account->id || ($metadata['user_id'] ?? null) !== $userId) { + throw new \RuntimeException('Browser terminal session not found.'); + } + + return $metadata; + } + + private function appendEvent(string $sessionId, array $payload): void + { + file_put_contents( + $this->eventLogPath($sessionId), + json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL, + FILE_APPEND | LOCK_EX + ); + } + + private function readMetadata(string $sessionId): ?array + { + $path = $this->metadataPath($sessionId); + + if (! is_file($path)) { + return null; + } + + $decoded = json_decode((string) file_get_contents($path), true); + + return is_array($decoded) ? $decoded : null; + } + + private function writeMetadata(string $sessionId, array $metadata): void + { + file_put_contents( + $this->metadataPath($sessionId), + json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + LOCK_EX + ); + } + + private function metadataPath(string $sessionId): string + { + return $this->sessionDirectory($sessionId).'/meta.json'; + } + + private function eventLogPath(string $sessionId): string + { + return $this->sessionDirectory($sessionId).'/events.ndjson'; + } + + private function outputLogPath(string $sessionId): string + { + return $this->sessionDirectory($sessionId).'/output.log'; + } + + private function heartbeatPath(string $sessionId): string + { + return $this->sessionDirectory($sessionId).'/heartbeat'; + } + + private function sessionDirectory(string $sessionId): string + { + return storage_path('app/browser-terminal-sessions/'.$sessionId); + } + + private function normalizeColumns(int $cols): int + { + return max(40, min($cols, 240)); + } + + private function normalizeRows(int $rows): int + { + return max(10, min($rows, 80)); + } + + private function touchHeartbeat(string $sessionId): void + { + touch($this->heartbeatPath($sessionId)); + } + + private function buildWorkerCommand(string $sessionId): string + { + return sprintf( + 'cd %s && nohup %s artisan hosting:terminal-worker %s >> %s 2>&1 < /dev/null & echo $!', + escapeshellarg(base_path()), + escapeshellarg($this->resolvePhpCliBinary()), + escapeshellarg($sessionId), + escapeshellarg($this->outputLogPath($sessionId)) + ); + } + + private function resolvePhpCliBinary(): string + { + $candidates = array_filter([ + env('LADILL_PHP_CLI_BINARY'), + $this->isCliBinary(PHP_BINARY) ? PHP_BINARY : null, + $this->joinBinaryPath(PHP_BINDIR, 'php'), + $this->joinBinaryPath(PHP_BINDIR, 'php-cli'), + '/usr/bin/php', + '/usr/local/bin/php', + ]); + + foreach ($candidates as $candidate) { + if ($this->isCliBinary($candidate)) { + return $candidate; + } + } + + return PHP_BINARY; + } + + private function joinBinaryPath(string $directory, string $binary): string + { + return rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$binary; + } + + private function isCliBinary(?string $path): bool + { + if (! is_string($path) || trim($path) === '') { + return false; + } + + $basename = strtolower(basename($path)); + + if (str_contains($basename, 'fpm') || str_contains($basename, 'cgi')) { + return false; + } + + return is_file($path) && is_executable($path); + } + + private function waitForWorkerStartup(string $sessionId, array $metadata): array + { + $deadline = microtime(true) + 2.5; + + while (microtime(true) < $deadline) { + usleep(100000); + + $current = $this->readWorkerMetadata($sessionId); + + if (($current['status'] ?? 'starting') !== 'starting') { + return $current; + } + } + + $metadata['status'] = 'failed'; + $metadata['exit_code'] = 1; + $metadata['closed_at'] = now()->toIso8601String(); + $metadata['error'] = $this->workerBootstrapError($sessionId); + $this->writeMetadata($sessionId, $metadata); + + return $metadata; + } + + private function workerBootstrapError(string $sessionId): string + { + $output = trim((string) @file_get_contents($this->outputLogPath($sessionId))); + + if ($output !== '') { + $lines = preg_split("/\r\n|\n|\r/", $output) ?: []; + $tail = trim((string) end($lines)); + + if ($tail !== '') { + return 'Terminal worker failed to start: '.$tail; + } + } + + return 'Terminal worker failed to start.'; + } +} diff --git a/app/Services/Hosting/Contracts/AppInstallerInterface.php b/app/Services/Hosting/Contracts/AppInstallerInterface.php new file mode 100644 index 0000000..85f5260 --- /dev/null +++ b/app/Services/Hosting/Contracts/AppInstallerInterface.php @@ -0,0 +1,25 @@ + + */ + public function capabilities(): array; + + public function supports(string $capability): bool; + + /** + * @return array + */ + public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array; + + /** + * @return array + */ + public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array; + + /** + * @return array + */ + public function executeCommandAsRoot(HostingAccount $account, string $command): array; + + /** + * @return array + */ + public function getUsageStats(HostingAccount $account): array; + + public function changePassword(HostingAccount $account, string $newPassword): bool; + + public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void; + + public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void; + + /** + * @return array + */ + public function createDatabase(HostedDatabase $database, string $password): array; + + public function deleteDatabase(HostedDatabase $database): bool; + + public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool; + + /** + * @return array + */ + public function addSite(HostedSite $site): array; + + public function removeSite(HostedSite $site): bool; + + public function requestLetsEncryptCertificate(HostedSite $site): bool; + + public function ensurePhpFpmPool(HostingAccount $account): void; + + public function setPhpVersion(HostedSite $site, string $version): bool; + + public function changeAccountPhpVersion(HostingAccount $account, string $version): bool; + + public function prepareDirectoryForUser(HostingAccount $account, string $path): void; + + public function setWebServerGroupOwnership(HostingAccount $account, string $path): void; + + public function removeAppService(HostedSite $site): bool; + + public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void; +} diff --git a/app/Services/Hosting/Contracts/SharedHostingProviderInterface.php b/app/Services/Hosting/Contracts/SharedHostingProviderInterface.php new file mode 100644 index 0000000..7e7c4da --- /dev/null +++ b/app/Services/Hosting/Contracts/SharedHostingProviderInterface.php @@ -0,0 +1,49 @@ + PHP versions that currently have a pool config for this account. + */ + public function findPhpFpmPoolVersions(HostingAccount $account): array; + + public function getUsageStats(HostingAccount $account): array; + + public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array; +} diff --git a/app/Services/Hosting/HostingExpiryAlertService.php b/app/Services/Hosting/HostingExpiryAlertService.php new file mode 100644 index 0000000..e5fd764 --- /dev/null +++ b/app/Services/Hosting/HostingExpiryAlertService.php @@ -0,0 +1,77 @@ + */ + public function milestones(): array + { + return array_values(array_map( + 'intval', + (array) config('notifications.hosting_expiry_milestones', [30, 14, 7, 3, 1]) + )); + } + + public function checkAll(): int + { + $sent = 0; + + HostingAccount::query() + ->where('status', HostingAccount::STATUS_ACTIVE) + ->whereNotNull('expires_at') + ->where('expires_at', '>', now()) + ->with('user') + ->each(function (HostingAccount $account) use (&$sent): void { + if ($this->checkAccount($account)) { + $sent++; + } + }); + + return $sent; + } + + public function checkAccount(HostingAccount $account): bool + { + if (! $account->expires_at || ! $account->user || $account->expires_at->isPast()) { + return false; + } + + $daysRemaining = $this->daysUntilExpiry($account); + if (! in_array($daysRemaining, $this->milestones(), true)) { + return false; + } + + $metadata = is_array($account->metadata) ? $account->metadata : []; + $sent = array_map('intval', (array) ($metadata['expiry_alerts_sent'] ?? [])); + if (in_array($daysRemaining, $sent, true)) { + return false; + } + + $account->user->notify(new HostingExpiringNotification($account, $daysRemaining)); + + $metadata['expiry_alerts_sent'] = array_values(array_unique([...$sent, $daysRemaining])); + $account->update(['metadata' => $metadata]); + + return true; + } + + public function resetAlerts(HostingAccount $account): void + { + $metadata = is_array($account->metadata) ? $account->metadata : []; + unset($metadata['expiry_alerts_sent']); + $account->update(['metadata' => $metadata]); + } + + private function daysUntilExpiry(HostingAccount $account): int + { + return (int) now()->startOfDay()->diffInDays( + $account->expires_at->copy()->startOfDay(), + false + ); + } +} diff --git a/app/Services/Hosting/HostingMailboxService.php b/app/Services/Hosting/HostingMailboxService.php new file mode 100644 index 0000000..7363e4e --- /dev/null +++ b/app/Services/Hosting/HostingMailboxService.php @@ -0,0 +1,190 @@ + + */ + public function usageSummary(HostingAccount $account): array + { + $account->loadMissing(['product']); + + $mailboxes = $account->loadedMailboxes(); + $freeAllowance = $account->freeEmailAllowance(); + $mailboxCount = $mailboxes->count(); + $paidCount = $mailboxes->where('is_paid', true)->count(); + $freeUsed = $freeAllowance === null ? $mailboxCount : min($mailboxCount, $freeAllowance); + $extraCount = max($mailboxCount - ($freeAllowance ?? $mailboxCount), 0); + + return [ + 'mailbox_count' => $mailboxCount, + 'free_allowance' => $freeAllowance, + 'free_used' => $freeUsed, + 'paid_count' => $paidCount, + 'extra_count' => $extraCount, + 'extra_price' => self::EXTRA_MAILBOX_PRICE_CEDIS, + 'extra_total' => round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2), + 'breakdown' => sprintf( + 'Extra Emails: %d x GHS %d = GHS %.2f', + $extraCount, + self::EXTRA_MAILBOX_PRICE_CEDIS, + round($extraCount * self::EXTRA_MAILBOX_PRICE_CEDIS, 2) + ), + ]; + } + + public function createMailbox(HostingAccount $account, array $input): array + { + $account->loadMissing(['product', 'sites']); + + if (! HostingAccount::tracksLocalMailboxes()) { + throw ValidationException::withMessages([ + 'local_part' => 'Mailbox creation is managed in Ladill Email.', + ]); + } + + $account->loadMissing(['mailboxes']); + + $domain = Domain::query() + ->whereKey((int) ($input['domain_id'] ?? 0)) + ->where('hosting_account_id', $account->id) + ->first(); + + if (! $domain) { + throw ValidationException::withMessages([ + 'domain_id' => 'Select a domain linked to this hosting account.', + ]); + } + + $localPart = strtolower(trim((string) ($input['local_part'] ?? ''))); + $address = "{$localPart}@{$domain->host}"; + + if (Mailbox::query()->where('address', $address)->exists()) { + throw ValidationException::withMessages([ + 'local_part' => 'This mailbox address already exists.', + ]); + } + + $summary = $this->usageSummary($account); + $freeAllowance = $summary['free_allowance']; + $isPaid = $freeAllowance !== null && $summary['mailbox_count'] >= $freeAllowance; + + $mailbox = Mailbox::query()->create([ + 'user_id' => $account->user_id, + 'website_id' => null, + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'display_name' => (string) ($input['display_name'] ?? $localPart), + 'local_part' => $localPart, + 'address' => $address, + 'password_hash' => Hash::make((string) $input['password']), + 'quota_mb' => (int) ($input['quota_mb'] ?? Mailbox::defaultQuotaMb()), + 'status' => 'provisioning', + 'is_paid' => $isPaid, + 'paid_at' => $isPaid ? now() : null, + 'billing_type' => $isPaid ? 'subscription' : 'free', + 'monthly_price' => $isPaid ? self::EXTRA_MAILBOX_PRICE_CEDIS : null, + 'subscription_started_at' => $isPaid ? now() : null, + 'subscription_expires_at' => $isPaid ? now()->addMonth() : null, + 'subscription_status' => 'active', + 'maildir_path' => sprintf('hosting/%d/%s/%s', $account->id, $domain->host, $localPart), + 'credentials_issued_at' => now(), + 'incoming_token' => Str::random(64), + 'inbound_enabled' => true, + 'outbound_enabled' => true, + 'outbound_provider' => 'smtp', + 'metadata' => [ + 'provisioned_by' => 'hosting_account', + 'hosting_account_id' => $account->id, + ], + ]); + + $this->mailboxUsers->provisionMailboxSilently($mailbox, (string) $input['password']); + + ProvisionMailboxJob::dispatch($mailbox->id, encrypt((string) $input['password'])); + + $invoice = $this->syncAddonInvoice($account->fresh()); + + return [ + 'mailbox' => $mailbox->fresh(['domain']), + 'usage' => $this->usageSummary($account->fresh(['product'])), + 'invoice' => $invoice, + ]; + } + + public function deleteMailbox(HostingAccount $account, Mailbox $mailbox): ?HostingBillingInvoice + { + if ((int) $mailbox->hosting_account_id !== (int) $account->id) { + throw ValidationException::withMessages([ + 'mailbox_id' => 'Mailbox does not belong to this hosting account.', + ]); + } + + $mailbox->update(['status' => 'deleting']); + DeactivateMailboxJob::dispatch($mailbox->id); + + return $this->syncAddonInvoice($account->fresh()); + } + + public function syncAddonInvoice(HostingAccount $account): ?HostingBillingInvoice + { + $account->loadMissing(['product']); + $summary = $this->usageSummary($account); + if ($summary['extra_count'] === 0) { + HostingBillingInvoice::query() + ->where('hosting_account_id', $account->id) + ->where('invoice_type', HostingBillingInvoice::TYPE_EMAIL_ADDON) + ->where('status', HostingBillingInvoice::STATUS_PENDING) + ->delete(); + + return null; + } + + return HostingBillingInvoice::query()->updateOrCreate( + [ + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON, + 'status' => HostingBillingInvoice::STATUS_PENDING, + ], + [ + 'user_id' => $account->user_id, + 'currency' => 'GHS', + 'subtotal_amount' => $summary['extra_total'], + 'credit_amount' => 0, + 'total_amount' => $summary['extra_total'], + 'description' => sprintf('Extra mailbox charges for %s', $account->primary_domain ?: $account->username), + 'line_items' => [[ + 'code' => 'extra_emails', + 'label' => 'Extra Emails', + 'quantity' => $summary['extra_count'], + 'unit_amount' => self::EXTRA_MAILBOX_PRICE_CEDIS, + 'amount' => $summary['extra_total'], + 'description' => $summary['breakdown'], + ]], + 'metadata' => [ + 'billing_period' => now()->format('Y-m'), + 'extra_mailboxes' => $summary['extra_count'], + ], + ] + ); + } +} diff --git a/app/Services/Hosting/HostingNodePoolService.php b/app/Services/Hosting/HostingNodePoolService.php new file mode 100644 index 0000000..9d12cdd --- /dev/null +++ b/app/Services/Hosting/HostingNodePoolService.php @@ -0,0 +1,86 @@ +isExtension() && $node->primary + ? $node->primary + : $node; + } + + /** + * @return Collection + */ + public function poolMembers(HostingNode $node): Collection + { + $root = $this->poolRoot($node); + + return HostingNode::query() + ->where(function ($query) use ($root) { + $query->where('id', $root->id) + ->orWhere('primary_hosting_node_id', $root->id); + }) + ->where('type', HostingNode::TYPE_SHARED) + ->whereNotIn('status', [HostingNode::STATUS_DECOMMISSIONED]) + ->orderBy('role') + ->get(); + } + + public function poolDiskGb(HostingNode $node): int + { + return (int) $this->poolMembers($node)->sum('disk_gb'); + } + + public function poolLogicalCapacityGb(HostingNode $node): float + { + return round($this->poolMembers($node)->sum(fn (HostingNode $m) => $m->logicalCapacityGb()), 2); + } + + public function poolAllocatedDiskGb(HostingNode $node): int + { + $memberIds = $this->poolMembers($node)->pluck('id'); + + return (int) HostingAccount::query() + ->whereIn('hosting_node_id', $memberIds) + ->where('type', HostingAccount::TYPE_SHARED) + ->sum('allocated_disk_gb'); + } + + public function poolUsedDiskGb(HostingNode $node): int + { + $memberIds = $this->poolMembers($node)->pluck('id'); + + $bytes = (int) HostingAccount::query() + ->whereIn('hosting_node_id', $memberIds) + ->where('type', HostingAccount::TYPE_SHARED) + ->sum('disk_used_bytes'); + + return (int) ceil($bytes / 1073741824); + } + + /** + * Member node with the most room for a new shared account. + */ + public function findProvisioningMember(HostingNode $poolRoot, int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode + { + $members = $this->poolMembers($poolRoot) + ->filter(fn (HostingNode $n) => $n->canProvision($requestedDiskGb, $segment)); + + if ($members->isEmpty()) { + return null; + } + + return $members->sortBy(fn (HostingNode $n) => sprintf( + '%012.2f-%012d', + (float) $n->current_load_percent, + $n->used_disk_gb, + ))->first(); + } +} diff --git a/app/Services/Hosting/HostingOrderFulfillmentService.php b/app/Services/Hosting/HostingOrderFulfillmentService.php new file mode 100644 index 0000000..c7865fc --- /dev/null +++ b/app/Services/Hosting/HostingOrderFulfillmentService.php @@ -0,0 +1,68 @@ +} + */ + public function attemptAutoFulfillment(CustomerHostingOrder $order): array + { + $order->refresh()->loadMissing('product'); + + if (! $this->isEligibleForAutoFulfillment($order)) { + return ['fulfilled' => false, 'reasons' => ['Order is not eligible for automatic fulfillment.']]; + } + + $this->approveAndQueue($order); + + return ['fulfilled' => true, 'reasons' => []]; + } + + public function approveAndQueue(CustomerHostingOrder $order): void + { + if ($order->status === CustomerHostingOrder::STATUS_APPROVED) { + ProvisionHostingOrderJob::dispatch($order->fresh()); + + return; + } + + $meta = $order->meta ?? []; + unset($meta['upstream_funds_hold'], $meta['upstream_funds_hold_at'], $meta['provisioning_hold']); + + $order->update([ + 'status' => CustomerHostingOrder::STATUS_APPROVED, + 'approved_at' => now(), + 'approved_by' => null, + 'meta' => $meta, + ]); + + ProvisionHostingOrderJob::dispatch($order->fresh()); + + Log::info('Hosting order queued for provisioning', [ + 'order_id' => $order->id, + 'domain_name' => $order->domain_name, + ]); + } + + private function isEligibleForAutoFulfillment(CustomerHostingOrder $order): bool + { + return in_array($order->status, [ + CustomerHostingOrder::STATUS_PENDING_APPROVAL, + CustomerHostingOrder::STATUS_PENDING_PAYMENT, + ], true) || ( + $order->status === CustomerHostingOrder::STATUS_APPROVED + && $order->provisioned_at === null + && $order->hosting_account_id === null + && $order->vps_instance_id === null + ); + } +} diff --git a/app/Services/Hosting/HostingPlanChangeService.php b/app/Services/Hosting/HostingPlanChangeService.php new file mode 100644 index 0000000..c23edaf --- /dev/null +++ b/app/Services/Hosting/HostingPlanChangeService.php @@ -0,0 +1,322 @@ + + */ + public function availablePlans(): \Illuminate\Database\Eloquent\Collection + { + return HostingProduct::query() + ->active() + ->visible() + ->where('category', HostingProduct::CATEGORY_SHARED) + ->ordered() + ->get(); + } + + /** + * @return array + */ + public function quote(HostingAccount $account, HostingProduct $targetProduct): array + { + $account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']); + $this->assertCanChange($account, $targetProduct); + + $currentProduct = $account->product; + $order = $account->latestOrder; + $billingCycle = $order?->billing_cycle ?? CustomerHostingOrder::CYCLE_MONTHLY; + $cycleMonths = $order?->getCycleLengthInMonths() ?? 1; + $currentCyclePrice = $this->cyclePriceFor($currentProduct, $billingCycle); + $targetCyclePrice = $this->cyclePriceFor($targetProduct, $billingCycle); + $remaining = $this->remainingCycleState($account, $order, $cycleMonths); + $priceDelta = round($targetCyclePrice - $currentCyclePrice, 2); + $direction = $priceDelta >= 0 + ? HostingPlanChange::DIRECTION_UPGRADE + : HostingPlanChange::DIRECTION_DOWNGRADE; + $proratedDelta = round($priceDelta * $remaining['ratio'], 2); + $chargeAmount = $proratedDelta > 0 ? $proratedDelta : 0; + $creditAmount = $proratedDelta < 0 ? abs($proratedDelta) : 0; + + return [ + 'direction' => $direction, + 'billing_cycle' => $billingCycle, + 'cycle_months' => $cycleMonths, + 'remaining_days' => $remaining['days'], + 'proration_ratio' => $remaining['ratio'], + 'current_cycle_price' => $currentCyclePrice, + 'target_cycle_price' => $targetCyclePrice, + 'charge_amount' => $chargeAmount, + 'credit_amount' => $creditAmount, + 'net_amount' => round($chargeAmount - $creditAmount, 2), + 'currency' => $targetProduct->display_currency, + 'line_items' => $this->lineItems($account, $targetProduct, $direction, $chargeAmount, $creditAmount, $remaining['ratio']), + ]; + } + + /** + * @return array{change: HostingPlanChange, invoice: HostingBillingInvoice, account: HostingAccount, quote: array} + */ + public function apply(HostingAccount $account, HostingProduct $targetProduct, User $actor): array + { + $account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']); + $quote = $this->quote($account, $targetProduct); + $currentProduct = $account->product; + + return DB::transaction(function () use ($account, $targetProduct, $actor, $quote, $currentProduct): array { + $change = HostingPlanChange::query()->create([ + 'user_id' => $actor->id, + 'hosting_account_id' => $account->id, + 'from_hosting_product_id' => $currentProduct->id, + 'to_hosting_product_id' => $targetProduct->id, + 'direction' => $quote['direction'], + 'billing_cycle' => $quote['billing_cycle'], + 'cycle_months' => $quote['cycle_months'], + 'remaining_days' => $quote['remaining_days'], + 'proration_ratio' => $quote['proration_ratio'], + 'old_cycle_price' => $quote['current_cycle_price'], + 'new_cycle_price' => $quote['target_cycle_price'], + 'charge_amount' => $quote['charge_amount'], + 'credit_amount' => $quote['credit_amount'], + 'net_amount' => $quote['net_amount'], + 'currency' => $quote['currency'], + 'status' => 'applied', + 'applied_at' => now(), + 'metadata' => [ + 'actor_id' => $actor->id, + 'sites_count' => $account->sites->count(), + 'databases_count' => $account->databases->count(), + ], + ]); + + $invoiceStatus = $quote['charge_amount'] > 0 + ? HostingBillingInvoice::STATUS_PENDING + : ($quote['credit_amount'] > 0 + ? HostingBillingInvoice::STATUS_CREDITED + : HostingBillingInvoice::STATUS_WAIVED); + + $invoice = HostingBillingInvoice::query()->create([ + 'user_id' => $actor->id, + 'hosting_account_id' => $account->id, + 'hosting_plan_change_id' => $change->id, + 'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE, + 'status' => $invoiceStatus, + 'currency' => $quote['currency'], + 'subtotal_amount' => $quote['charge_amount'], + 'credit_amount' => $quote['credit_amount'], + 'total_amount' => max($quote['net_amount'], 0), + 'description' => sprintf( + '%s %s from %s to %s', + ucfirst($quote['direction']), + $account->primary_domain ?: $account->username, + $currentProduct->name, + $targetProduct->name + ), + 'paid_at' => $invoiceStatus !== HostingBillingInvoice::STATUS_PENDING ? now() : null, + 'line_items' => $quote['line_items'], + 'metadata' => [ + 'direction' => $quote['direction'], + 'billing_cycle' => $quote['billing_cycle'], + 'remaining_days' => $quote['remaining_days'], + ], + ]); + + $this->applyProductToAccount($account, $targetProduct, $change, $actor); + + return [ + 'change' => $change->fresh(['fromProduct', 'toProduct']), + 'invoice' => $invoice->fresh(), + 'account' => $account->fresh(['product', 'node']), + 'quote' => $quote, + ]; + }); + } + + private function assertCanChange(HostingAccount $account, HostingProduct $targetProduct): void + { + if (! $account->product || ! $account->product->isSharedHosting()) { + throw ValidationException::withMessages([ + 'hosting_account_id' => 'Only shared hosting accounts can change packages.', + ]); + } + + if (! $targetProduct->isSharedHosting() || ! $targetProduct->is_active || ! $targetProduct->is_visible) { + throw ValidationException::withMessages([ + 'target_product_id' => 'Selected plan is not available for hosting upgrades.', + ]); + } + + if ((int) $account->hosting_product_id === (int) $targetProduct->id) { + throw ValidationException::withMessages([ + 'target_product_id' => 'Account is already on that plan.', + ]); + } + + $siteLimit = (int) ($targetProduct->max_domains ?? 0); + if ($siteLimit >= 0 && $account->sites->count() > $siteLimit) { + throw ValidationException::withMessages([ + 'target_product_id' => "The target plan supports {$siteLimit} site(s), but this account already has {$account->sites->count()}.", + ]); + } + + $databaseLimit = $targetProduct->max_databases; + if ($databaseLimit !== null && $databaseLimit >= 0 && $account->databases->count() > $databaseLimit) { + throw ValidationException::withMessages([ + 'target_product_id' => "The target plan supports {$databaseLimit} database(s), but this account already has {$account->databases->count()}.", + ]); + } + + $diskDelta = max((int) ($targetProduct->disk_gb ?? 0) - (int) ($account->allocated_disk_gb ?? 0), 0); + if ($diskDelta > 0 && $account->node && ! $this->capacityService->canProvision($account->node, $diskDelta)) { + throw ValidationException::withMessages([ + 'target_product_id' => 'The assigned server does not have enough logical capacity for this upgrade.', + ]); + } + } + + private function cyclePriceFor(?HostingProduct $product, string $billingCycle): float + { + if (! $product) { + return 0; + } + + $price = $product->getPriceForCycle($billingCycle); + + if ($price === null) { + $price = $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY); + } + + return round((float) $price, 2); + } + + /** + * @return array{ratio: float, days: int} + */ + private function remainingCycleState(HostingAccount $account, ?CustomerHostingOrder $order, int $cycleMonths): array + { + $periodEnd = $account->expires_at + ?? $order?->expires_at + ?? now()->copy()->addMonthsNoOverflow($cycleMonths); + + if (! $periodEnd || $periodEnd->lte(now())) { + return ['ratio' => 1.0, 'days' => 0]; + } + + $periodStart = $periodEnd->copy()->subMonthsNoOverflow(max($cycleMonths, 1)); + $totalSeconds = max($periodStart->diffInSeconds($periodEnd, false), 1); + $remainingSeconds = max(now()->diffInSeconds($periodEnd, false), 0); + + return [ + 'ratio' => round(min(max($remainingSeconds / $totalSeconds, 0), 1), 4), + 'days' => (int) ceil($remainingSeconds / 86400), + ]; + } + + /** + * @return array> + */ + private function lineItems( + HostingAccount $account, + HostingProduct $targetProduct, + string $direction, + float $chargeAmount, + float $creditAmount, + float $ratio + ): array { + $currentProduct = $account->product; + + return array_values(array_filter([ + [ + 'code' => 'plan_change', + 'label' => sprintf('%s to %s', ucfirst($direction), $targetProduct->name), + 'description' => sprintf('Plan change from %s to %s.', $currentProduct?->name ?? 'Current plan', $targetProduct->name), + 'amount' => round($chargeAmount, 2), + ], + $creditAmount > 0 ? [ + 'code' => 'proration_credit', + 'label' => 'Unused time credit', + 'description' => sprintf('Prorated credit at %.2f%% of the current cycle.', $ratio * 100), + 'amount' => round($creditAmount * -1, 2), + ] : null, + ])); + } + + private function applyProductToAccount(HostingAccount $account, HostingProduct $targetProduct, HostingPlanChange $change, User $actor): void + { + $policyLimits = $this->resourcePolicyService->defaultLimitsForProduct($targetProduct); + $resourceLimits = array_merge((array) ($account->resource_limits ?? []), [ + 'disk_gb' => (int) ($targetProduct->disk_gb ?? 0), + 'bandwidth_gb' => $targetProduct->bandwidth_gb, + 'max_domains' => $targetProduct->max_domains, + 'max_databases' => $targetProduct->max_databases, + 'max_email_accounts' => $targetProduct->max_email_accounts, + 'max_ftp_accounts' => $targetProduct->max_ftp_accounts, + ], $policyLimits); + + $metadata = array_merge((array) ($account->metadata ?? []), [ + 'last_plan_change_id' => $change->id, + 'last_plan_change_at' => now()->toIso8601String(), + 'last_plan_changed_by' => $actor->id, + ]); + + $account->update([ + 'hosting_product_id' => $targetProduct->id, + 'allocated_disk_gb' => (int) ($targetProduct->disk_gb ?? 0), + 'cpu_limit_percent' => $policyLimits['cpu_limit_percent'] ?? $account->cpu_limit_percent, + 'memory_limit_mb' => $policyLimits['memory_limit_mb'] ?? $account->memory_limit_mb, + 'process_limit' => $policyLimits['process_limit'] ?? $account->process_limit, + 'io_limit_mb' => $policyLimits['io_limit_mb'] ?? $account->io_limit_mb, + 'inode_limit' => $policyLimits['inode_limit'] ?? $account->inode_limit, + 'resource_limits' => $resourceLimits, + 'metadata' => $metadata, + ]); + + $account->refresh(); + + $activeOrder = CustomerHostingOrder::query() + ->where('hosting_account_id', $account->id) + ->whereIn('status', [ + CustomerHostingOrder::STATUS_ACTIVE, + CustomerHostingOrder::STATUS_SUSPENDED, + CustomerHostingOrder::STATUS_PROVISIONING, + ]) + ->latest('id') + ->first(); + + if ($activeOrder) { + $activeOrder->update([ + 'hosting_product_id' => $targetProduct->id, + 'currency' => $targetProduct->display_currency, + 'meta' => array_merge((array) ($activeOrder->meta ?? []), [ + 'last_plan_change_id' => $change->id, + 'last_plan_change_direction' => $change->direction, + ]), + ]); + } + + $this->sharedNodeProvider->syncAccountPackage($account); + + if ($account->node) { + $this->capacityService->refreshNodeResourceTotals($account->node); + } + } +} diff --git a/app/Services/Hosting/HostingPricingService.php b/app/Services/Hosting/HostingPricingService.php new file mode 100644 index 0000000..5b35961 --- /dev/null +++ b/app/Services/Hosting/HostingPricingService.php @@ -0,0 +1,242 @@ +exchangeRateService = $exchangeRateService; + $this->contaboProvider = $contaboProvider; + $this->margins = config('hosting.pricing.margins', [ + 'vps' => 30, + 'dedicated' => 30, + ]); + $this->contaboBasePrices = config('hosting.pricing.contabo_base_prices', []); + } + + public function getExchangeRate(): float + { + return $this->exchangeRateService->getUsdToGhs(); + } + + public function getMargin(string $category): float + { + return (float) ($this->margins[$category] ?? 30); + } + + public function getContaboBasePrice(?string $productId): ?array + { + if ($productId === null) { + return null; + } + + $productId = trim($productId); + + if ($productId === '') { + return null; + } + + $cacheKey = "contabo_product_price_{$productId}"; + + return Cache::remember($cacheKey, (int) config('hosting.pricing.contabo_price_cache_ttl', 3600), function () use ($productId) { + $liveProduct = $this->getLiveContaboProduct($productId); + + if ($liveProduct !== null) { + return $liveProduct + ['source' => 'contabo_api']; + } + + $fallback = $this->contaboBasePrices[$productId] ?? null; + + return is_array($fallback) ? $fallback + ['source' => 'local_fallback'] : null; + }); + } + + public function calculatePrice(float $basePriceUsd, string $category): float + { + $margin = $this->getMargin($category); + $exchangeRate = $this->getExchangeRate(); + $convertedPrice = $basePriceUsd * $exchangeRate; + $priceWithMargin = $convertedPrice * (1 + ($margin / 100)); + + // Round to nearest 5 for cleaner pricing + return round($priceWithMargin / 5) * 5; + } + + public function calculateQuarterlyPrice(float $monthlyPrice): float + { + return $this->calculateTermPrice($monthlyPrice, 3, 'quarterly'); + } + + public function calculateSemiannualPrice(float $monthlyPrice): float + { + return $this->calculateTermPrice($monthlyPrice, 6, 'semiannual'); + } + + public function calculateYearlyPrice(float $monthlyPrice): float + { + return $this->calculateTermPrice($monthlyPrice, 12, 'yearly'); + } + + public function convertUsdRecurringOption(float $monthlyUsd, string $category): float + { + $margin = $this->getMargin($category); + $exchangeRate = $this->getExchangeRate(); + + return round($monthlyUsd * $exchangeRate * (1 + ($margin / 100)), 2); + } + + private function calculateTermPrice(float $monthlyPrice, int $months, string $cycle): float + { + $discountPercent = (float) config("hosting.pricing.term_discounts.{$cycle}", 0); + $total = $monthlyPrice * $months * (1 - ($discountPercent / 100)); + + return round($total / 5) * 5; + } + + public function getDynamicPriceForProduct(HostingProduct $product): array + { + // Only calculate dynamic pricing for VPS and Dedicated + if (!in_array($product->category, ['vps', 'dedicated'])) { + return [ + 'monthly' => (float) $product->price_monthly, + 'quarterly' => (float) $product->price_quarterly, + 'semiannual' => null, + 'yearly' => (float) $product->price_yearly, + 'currency' => $product->display_currency, + 'is_dynamic' => false, + ]; + } + + $exchangeRate = $this->getExchangeRate(); + $cacheKey = "hosting_price_{$product->slug}_{$product->contabo_product_id}_{$exchangeRate}"; + + return Cache::remember($cacheKey, 3600, function () use ($product, $exchangeRate) { + $contaboProduct = $this->getContaboBasePrice($product->contabo_product_id); + + if (!$contaboProduct) { + // Fallback to stored prices if no Contabo mapping + return [ + 'monthly' => (float) $product->price_monthly, + 'quarterly' => (float) $product->price_quarterly, + 'semiannual' => null, + 'yearly' => (float) $product->price_yearly, + 'currency' => $product->display_currency, + 'is_dynamic' => false, + ]; + } + + $basePriceUsd = $contaboProduct['monthly']; + $monthlyPrice = $this->calculatePrice($basePriceUsd, $product->category); + + return [ + 'monthly' => $monthlyPrice, + 'quarterly' => $this->calculateQuarterlyPrice($monthlyPrice), + 'semiannual' => $this->calculateSemiannualPrice($monthlyPrice), + 'yearly' => $this->calculateYearlyPrice($monthlyPrice), + 'currency' => 'GHS', + 'is_dynamic' => true, + 'base_price_usd' => $basePriceUsd, + 'exchange_rate' => $exchangeRate, + 'margin_percent' => $this->getMargin($product->category), + ]; + }); + } + + public function getPricingBreakdown(HostingProduct $product): array + { + if (!in_array($product->category, ['vps', 'dedicated'])) { + return []; + } + + $contaboProduct = $this->getContaboBasePrice($product->contabo_product_id); + + if (!$contaboProduct) { + return []; + } + + $basePriceUsd = $contaboProduct['monthly']; + $margin = $this->getMargin($product->category); + $exchangeRate = $this->getExchangeRate(); + $convertedPrice = $basePriceUsd * $exchangeRate; + $marginAmount = $convertedPrice * ($margin / 100); + $finalPrice = $this->calculatePrice($basePriceUsd, $product->category); + + return [ + 'contabo_price_usd' => $basePriceUsd, + 'price_source' => (string) ($contaboProduct['source'] ?? 'unknown'), + 'exchange_rate' => $exchangeRate, + 'converted_ghs' => round($convertedPrice, 2), + 'margin_percent' => $margin, + 'margin_amount_ghs' => round($marginAmount, 2), + 'final_price_ghs' => $finalPrice, + 'profit_per_month_ghs' => round($finalPrice - $convertedPrice, 2), + ]; + } + + public function getExchangeRateInfo(): array + { + return $this->exchangeRateService->getRateInfo(); + } + + public function refreshExchangeRate(): float + { + return $this->exchangeRateService->refreshRate(); + } + + private function getLiveContaboProduct(string $productId): ?array + { + try { + foreach ($this->contaboProvider->getAvailableProducts() as $product) { + if ((string) ($product['id'] ?? '') !== $productId) { + continue; + } + + $monthlyUsd = $this->extractMonthlyUsdPrice($product); + + if ($monthlyUsd === null || $monthlyUsd <= 0) { + return null; + } + + return [ + 'monthly' => $monthlyUsd, + 'name' => (string) ($product['name'] ?? $productId), + 'cpu' => isset($product['cpu']) ? (int) $product['cpu'] : null, + 'ram_gb' => isset($product['ram_gb']) ? (float) $product['ram_gb'] : null, + 'disk_gb' => isset($product['disk_gb']) ? (float) $product['disk_gb'] : null, + ]; + } + } catch (\Throwable $exception) { + Log::warning('Contabo product price lookup failed; falling back to configured prices.', [ + 'product_id' => $productId, + 'message' => $exception->getMessage(), + ]); + } + + return null; + } + + private function extractMonthlyUsdPrice(array $product): ?float + { + foreach (['price_monthly', 'monthly', 'monthly_usd', 'monthlyUsd', 'monthlyPrice', 'price'] as $key) { + if (! isset($product[$key]) || ! is_numeric($product[$key])) { + continue; + } + + return (float) $product[$key]; + } + + return null; + } +} diff --git a/app/Services/Hosting/HostingProvisioningService.php b/app/Services/Hosting/HostingProvisioningService.php new file mode 100644 index 0000000..b428edb --- /dev/null +++ b/app/Services/Hosting/HostingProvisioningService.php @@ -0,0 +1,390 @@ +nodeCapacityService->findAvailableNode((int) $plan->disk_gb); + $resourceLimits = $this->resourcePolicyService->defaultLimitsForProduct(null); + + if (!$node) { + throw new \RuntimeException('No available hosting nodes for shared hosting'); + } + + // Generate unique username + $username = $this->generateUsername($user, $domain); + + // Create account record + $account = HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_plan_id' => $plan->id, + 'hosting_node_id' => $node->id, + 'username' => $username, + 'primary_domain' => $domain, + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_PROVISIONING, + 'allocated_disk_gb' => (int) $plan->disk_gb, + 'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'], + 'memory_limit_mb' => $resourceLimits['memory_limit_mb'], + 'process_limit' => $resourceLimits['process_limit'], + 'io_limit_mb' => $resourceLimits['io_limit_mb'], + 'inode_limit' => $resourceLimits['inode_limit'], + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'php_version' => $plan->php_versions[0] ?? '8.2', + 'resource_limits' => [ + 'disk_gb' => $plan->disk_gb, + 'bandwidth_gb' => $plan->bandwidth_gb, + 'max_domains' => $plan->max_domains, + 'max_databases' => $plan->max_databases, + 'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'], + 'memory_limit_mb' => $resourceLimits['memory_limit_mb'], + 'process_limit' => $resourceLimits['process_limit'], + 'io_limit_mb' => $resourceLimits['io_limit_mb'], + 'inode_limit' => $resourceLimits['inode_limit'], + ], + ]); + + // Create provisioning job + $job = ProvisioningJob::create([ + 'type' => 'create_shared_account', + 'provisionable_type' => HostingAccount::class, + 'provisionable_id' => $account->id, + 'provider' => 'shared_node', + 'status' => ProvisioningJob::STATUS_PENDING, + 'payload' => [ + 'username' => $username, + 'domain' => $domain, + 'node_id' => $node->id, + ], + ]); + + // Dispatch async provisioning + dispatch(function () use ($account, $job, $node, $domain) { + $this->provisionSharedAccount($account, $job, $node, $domain); + })->afterCommit(); + + return $account; + }); + } + + public function createVpsInstance(User $user, HostingPlan $plan, array $config): VpsInstance + { + return DB::transaction(function () use ($user, $plan, $config) { + $hostname = $config['hostname'] ?? 'vps-' . Str::random(8) . '.ladill.com'; + + $instance = VpsInstance::create([ + 'user_id' => $user->id, + 'hosting_plan_id' => $plan->id, + 'name' => $config['name'] ?? $hostname, + 'hostname' => $hostname, + 'provider' => 'contabo', + 'region' => $config['region'] ?? 'EU', + 'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'cpu_cores' => $plan->cpu_cores, + 'ram_mb' => $plan->ram_mb, + 'disk_gb' => $plan->disk_gb, + 'status' => 'provisioning', + 'ssh_public_key' => $config['ssh_public_key'] ?? null, + 'tags' => $config['tags'] ?? [], + ]); + + $job = ProvisioningJob::create([ + 'type' => 'create_vps_instance', + 'provisionable_type' => VpsInstance::class, + 'provisionable_id' => $instance->id, + 'provider' => 'contabo', + 'status' => ProvisioningJob::STATUS_PENDING, + 'payload' => [ + 'product_id' => $plan->contabo_product_id, + 'region' => $config['region'] ?? 'EU', + 'image' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'ssh_keys' => $config['ssh_keys'] ?? [], + 'user_data' => $config['user_data'] ?? null, + ], + ]); + + dispatch(function () use ($instance, $job, $config, $plan) { + $this->provisionVpsInstance($instance, $job, $config, $plan); + })->afterCommit(); + + return $instance; + }); + } + + public function addSiteToAccount(HostingAccount $account, string $domain, string $type = 'addon'): HostedSite + { + $docRoot = "/home/{$account->username}/public_html/{$domain}"; + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => $domain, + 'document_root' => $docRoot, + 'type' => $type, + 'status' => 'pending', + 'php_version' => $account->php_version, + ]); + + try { + $this->sharedNodeProvider->addSite($site); + $site->update(['status' => 'active']); + + // Sync to Domain registry + $this->syncToDomainRegistry($account, $domain, $site); + } catch (\Exception $e) { + $site->update(['status' => 'disabled']); + throw $e; + } + + return $site; + } + + /** + * Sync hosting site to unified Domain registry + */ + private function syncToDomainRegistry(HostingAccount $account, string $host, HostedSite $site): void + { + try { + $domain = Domain::where('host', $host)->first(); + + if ($domain) { + // Update existing domain to add hosting + $domain->update([ + 'hosting_account_id' => $account->id, + 'source' => $domain->email_domain_id ? 'hosting_email' : 'hosting', + 'onboarding_state' => $domain->onboarding_state === 'pending_ns' ? null : $domain->onboarding_state, + ]); + } else { + // Create new domain entry + Domain::create([ + 'user_id' => $account->user_id, + 'host' => $host, + 'hosting_account_id' => $account->id, + 'source' => 'hosting', + 'type' => 'custom', + 'status' => 'verified', + 'onboarding_state' => null, + 'verified_at' => now(), + ]); + } + + Log::info('HostingProvisioningService: Synced to Domain registry', [ + 'domain' => $host, + 'site_id' => $site->id, + ]); + } catch (\Throwable $e) { + Log::warning('HostingProvisioningService: Failed to sync to Domain registry', [ + 'domain' => $host, + 'error' => $e->getMessage(), + ]); + } + } + + public function suspendAccount(HostingAccount $account, string $reason): bool + { + $result = $this->sharedNodeProvider->suspendAccount($account, $reason); + + if ($result) { + $account->update([ + 'status' => HostingAccount::STATUS_SUSPENDED, + 'suspension_reason' => $reason, + 'suspended_at' => now(), + ]); + } + + return $result; + } + + public function unsuspendAccount(HostingAccount $account): bool + { + $result = $this->sharedNodeProvider->unsuspendAccount($account); + + if ($result) { + $account->update([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'suspension_reason' => null, + 'suspended_at' => null, + ]); + } + + return $result; + } + + public function terminateAccount(HostingAccount $account): bool + { + $result = $this->sharedNodeProvider->terminateAccount($account); + + if ($result) { + $account->node?->decrementAccountCount(); + $account->update(['status' => HostingAccount::STATUS_TERMINATED]); + $account->delete(); + } + + return $result; + } + + public function controlVps(VpsInstance $instance, string $action): bool + { + return match ($action) { + 'start' => $this->contaboProvider->startInstance($instance->provider_instance_id), + 'stop' => $this->contaboProvider->stopInstance($instance->provider_instance_id), + 'reboot' => $this->contaboProvider->rebootInstance($instance->provider_instance_id), + default => throw new \InvalidArgumentException("Unknown action: {$action}"), + }; + } + + public function terminateVps(VpsInstance $instance): bool + { + if ($instance->provider_instance_id) { + $this->contaboProvider->deleteInstance($instance->provider_instance_id); + } + + $instance->update(['status' => 'terminated']); + $instance->delete(); + + return true; + } + + private function provisionSharedAccount(HostingAccount $account, ProvisioningJob $job, HostingNode $node, string $domain): void + { + $job->markRunning(); + + try { + $result = $this->sharedNodeProvider->createAccount($account); + + $account->update([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'home_directory' => $result['home_directory'], + 'provisioned_at' => now(), + ]); + + // Create primary site + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => $domain, + 'document_root' => $result['document_root'], + 'type' => 'primary', + 'status' => 'pending', + 'php_version' => $account->php_version, + ]); + + $this->sharedNodeProvider->addSite($site); + $site->update(['status' => 'active']); + + // Sync to Domain registry + $this->syncToDomainRegistry($account, $domain, $site); + + $node->incrementAccountCount(); + + $job->markCompleted([ + 'username' => $result['username'], + 'home_directory' => $result['home_directory'], + 'initial_password' => $result['password'], + ]); + + Log::info("Shared hosting account provisioned", [ + 'account_id' => $account->id, + 'username' => $account->username, + ]); + + } catch (\Exception $e) { + Log::error("Shared hosting provisioning failed", [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + + $account->update(['status' => HostingAccount::STATUS_FAILED]); + $job->markFailed($e->getMessage()); + } + } + + private function provisionVpsInstance(VpsInstance $instance, ProvisioningJob $job, array $config, HostingPlan $plan): void + { + $job->markRunning(); + + try { + $rootPassword = Str::password(16); + + $result = $this->contaboProvider->createInstance([ + 'product_id' => $plan->contabo_product_id, + 'name' => $instance->name, + 'region' => $config['region'] ?? 'EU', + 'image_id' => $config['image'] ?? 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'root_password' => $rootPassword, + 'ssh_keys' => $config['ssh_keys'] ?? [], + 'user_data' => $config['user_data'] ?? null, + ]); + + $instance->update([ + 'provider_instance_id' => $result['instance_id'], + 'ip_address' => $result['ip_address'], + 'ipv6_address' => $result['ipv6_address'], + 'datacenter' => $result['datacenter'], + 'status' => 'running', + 'power_status' => 'on', + 'root_password_encrypted' => encrypt($rootPassword), + 'provisioned_at' => now(), + ]); + + $job->markCompleted([ + 'instance_id' => $result['instance_id'], + 'ip_address' => $result['ip_address'], + 'root_password' => $rootPassword, + ]); + + Log::info("VPS instance provisioned", [ + 'instance_id' => $instance->id, + 'provider_id' => $result['instance_id'], + ]); + + } catch (\Exception $e) { + Log::error("VPS provisioning failed", [ + 'instance_id' => $instance->id, + 'error' => $e->getMessage(), + ]); + + $instance->update(['status' => 'failed']); + $job->markFailed($e->getMessage()); + } + } + + private function generateUsername(User $user, string $domain): string + { + $base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0])); + $base = substr($base, 0, 8) ?: 'user'; + + $username = $base; + $counter = 1; + + while (HostingAccount::where('username', $username)->exists()) { + $username = $base . $counter; + $counter++; + } + + return $username; + } +} diff --git a/app/Services/Hosting/HostingRenewalCheckoutService.php b/app/Services/Hosting/HostingRenewalCheckoutService.php new file mode 100644 index 0000000..23b0ed5 --- /dev/null +++ b/app/Services/Hosting/HostingRenewalCheckoutService.php @@ -0,0 +1,242 @@ +loadMissing(['product', 'latestOrder']); + + if (! $account->canBeRenewed()) { + throw ValidationException::withMessages([ + 'hosting_account_id' => 'This hosting account does not have a configured renewal duration.', + ]); + } + + $months = $durationMonths ?? $account->assignedDurationMonths(); + $billingCycle = $this->billingCycleForMonths($months); + $amount = $this->renewalAmount($account, $months, $billingCycle); + + return [ + 'months' => $months, + 'amount' => round($amount, 2), + 'currency' => $account->product?->display_currency ?? 'GHS', + 'billing_cycle' => $billingCycle, + 'amount_minor' => (int) round($amount * 100), + ]; + } + + /** + * @return array + */ + public function availableRenewalOptions(HostingAccount $account): array + { + $account->loadMissing(['product']); + + if (! $account->canBeRenewed() || ! $account->product) { + return []; + } + + $currency = $account->product->display_currency ?? 'GHS'; + $currentMonths = $account->assignedDurationMonths(); + + $cycles = [ + ['months' => 1, 'cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'label' => '1 Month'], + ['months' => 3, 'cycle' => CustomerHostingOrder::CYCLE_QUARTERLY, 'label' => '3 Months'], + ['months' => 12, 'cycle' => CustomerHostingOrder::CYCLE_YEARLY, 'label' => '1 Year'], + ['months' => 24, 'cycle' => CustomerHostingOrder::CYCLE_BIENNIAL, 'label' => '2 Years'], + ]; + + $options = []; + + foreach ($cycles as $cycle) { + $amount = $this->renewalAmount($account, $cycle['months'], $cycle['cycle']); + + if ($amount <= 0) { + continue; + } + + $options[] = [ + 'months' => $cycle['months'], + 'amount' => round($amount, 2), + 'currency' => $currency, + 'billing_cycle' => $cycle['cycle'], + 'label' => $cycle['label'], + 'current' => $cycle['months'] === $currentMonths, + ]; + } + + return $options; + } + + /** + * @return array{authorization_url:string, invoice:HostingBillingInvoice, quote:array{months:int, amount:float, currency:string, billing_cycle:string, amount_minor:int}} + */ + public function beginCheckout(HostingAccount $account, User $user, string $callbackUrl, ?int $durationMonths = null): array + { + $quote = $this->renewalQuote($account, $durationMonths); + $reference = 'HOST-REN-' . strtoupper(Str::random(16)); + + $invoice = HostingBillingInvoice::query()->create([ + 'user_id' => $user->id, + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL, + 'status' => HostingBillingInvoice::STATUS_PENDING, + 'currency' => $quote['currency'], + 'subtotal_amount' => $quote['amount'], + 'credit_amount' => 0, + 'total_amount' => $quote['amount'], + 'description' => sprintf( + 'Hosting renewal for %s (%d month%s)', + $account->primary_domain ?: $account->username, + $quote['months'], + $quote['months'] === 1 ? '' : 's' + ), + 'provider_reference' => $reference, + 'line_items' => [[ + 'code' => 'renewal', + 'label' => 'Hosting Renewal', + 'quantity' => 1, + 'amount' => $quote['amount'], + 'description' => sprintf('%d month renewal for %s', $quote['months'], $account->product?->name ?? 'Hosting plan'), + ]], + 'metadata' => [ + 'months' => $quote['months'], + 'billing_cycle' => $quote['billing_cycle'], + ], + ]); + + $transaction = $this->paystack->initializeTransaction([ + 'email' => (string) $user->email, + 'amount' => $quote['amount_minor'], + 'currency' => $quote['currency'], + 'reference' => $reference, + 'callback_url' => $callbackUrl, + 'metadata' => [ + 'context' => 'hosting_renewal', + 'hosting_account_id' => $account->id, + 'invoice_id' => $invoice->id, + 'user_id' => $user->id, + 'months' => $quote['months'], + ], + ]); + + $authorizationUrl = (string) ($transaction['authorization_url'] ?? ''); + + if ($authorizationUrl === '') { + throw ValidationException::withMessages([ + 'payment' => 'Paystack did not return a checkout URL for the renewal.', + ]); + } + + return [ + 'authorization_url' => $authorizationUrl, + 'invoice' => $invoice, + 'quote' => $quote, + ]; + } + + public function completeCheckout(HostingAccount $account, User $user, string $reference): HostingBillingInvoice + { + $account->loadMissing(['latestOrder']); + + $invoice = HostingBillingInvoice::query() + ->where('hosting_account_id', $account->id) + ->where('user_id', $user->id) + ->where('invoice_type', HostingBillingInvoice::TYPE_RENEWAL) + ->where('provider_reference', $reference) + ->latest('id') + ->first(); + + if (! $invoice) { + throw ValidationException::withMessages([ + 'reference' => 'Renewal invoice could not be found for this payment reference.', + ]); + } + + if ($invoice->status === HostingBillingInvoice::STATUS_PAID) { + return $invoice; + } + + $transaction = $this->paystack->verifyTransaction($reference); + $status = strtolower((string) ($transaction['status'] ?? '')); + + if ($status !== 'success') { + throw ValidationException::withMessages([ + 'payment' => 'Renewal payment was not successful.', + ]); + } + + $months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0); + if ($months < 1) { + throw ValidationException::withMessages([ + 'payment' => 'Renewal duration is invalid for this hosting account.', + ]); + } + + $paidAt = now(); + $account->renew($months, [ + 'renewed_by_user' => $user->id, + 'renewed_at' => $paidAt->toIso8601String(), + 'renewal_invoice_id' => $invoice->id, + ]); + + if ($account->latestOrder) { + $account->latestOrder->update([ + 'expires_at' => $account->fresh()->expires_at, + 'meta' => array_merge((array) ($account->latestOrder->meta ?? []), [ + 'renewal_invoice_id' => $invoice->id, + 'renewed_at' => $paidAt->toIso8601String(), + ]), + ]); + } + + $invoice->update([ + 'status' => HostingBillingInvoice::STATUS_PAID, + 'paid_at' => $paidAt, + ]); + + return $invoice->fresh(); + } + + private function billingCycleForMonths(int $months): string + { + return match ($months) { + 1 => CustomerHostingOrder::CYCLE_MONTHLY, + 3 => CustomerHostingOrder::CYCLE_QUARTERLY, + 12 => CustomerHostingOrder::CYCLE_YEARLY, + 24 => CustomerHostingOrder::CYCLE_BIENNIAL, + default => CustomerHostingOrder::CYCLE_MONTHLY, + }; + } + + private function renewalAmount(HostingAccount $account, int $months, string $billingCycle): float + { + $price = $account->product?->getPriceForCycle($billingCycle); + + if ($price !== null && in_array($months, [1, 3, 12, 24], true)) { + return (float) $price; + } + + $monthly = (float) ($account->product?->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY) ?? 0); + + return $monthly * $months; + } +} diff --git a/app/Services/Hosting/HostingResourcePolicyService.php b/app/Services/Hosting/HostingResourcePolicyService.php new file mode 100644 index 0000000..9e96e8c --- /dev/null +++ b/app/Services/Hosting/HostingResourcePolicyService.php @@ -0,0 +1,642 @@ +type) { + HostingProduct::TYPE_WORDPRESS => [ + 'cpu_limit_percent' => 113, + 'memory_limit_mb' => 1152, + 'process_limit' => 23, + 'io_limit_mb' => 12, + 'inode_limit' => 450000, + ], + HostingProduct::TYPE_MULTI_DOMAIN => [ + 'cpu_limit_percent' => 150, + 'memory_limit_mb' => 1536, + 'process_limit' => 30, + 'io_limit_mb' => 15, + 'inode_limit' => 525000, + ], + default => [ + 'cpu_limit_percent' => 75, + 'memory_limit_mb' => 768, + 'process_limit' => 15, + 'io_limit_mb' => 8, + 'inode_limit' => 375000, + ], + }; + + $defaults = array_merge($defaults, $this->productSpecificLimits($product)); + + $overrides = (array) data_get($product?->features, 'resource_limits', []); + + return array_merge($defaults, array_filter($overrides, fn ($value) => $value !== null)); + } + + private function productSpecificLimits(?HostingProduct $product): array + { + return match ($product?->slug) { + 'starter-plan' => [ + 'inode_limit' => 225000, + ], + 'basic-plan' => [ + 'inode_limit' => 450000, + ], + 'plus-plan' => [ + 'inode_limit' => 450000, + ], + 'growth-plan' => [ + 'inode_limit' => 750000, + ], + 'pro-plan' => [ + 'inode_limit' => 1200000, + ], + default => [], + }; + } + + public function applyDefaultLimits(HostingAccount $account, bool $persist = true): array + { + $defaults = $this->defaultLimitsForProduct($account->product); + $resourceLimits = (array) ($account->resource_limits ?? []); + + $updates = []; + foreach ($defaults as $column => $value) { + if ($account->{$column} === null) { + $updates[$column] = $value; + } + + $resourceLimits[$column] = $account->{$column} ?? $value; + } + + if (! isset($resourceLimits['disk_gb'])) { + $resourceLimits['disk_gb'] = $account->requestedDiskGb(); + } + + if ($persist) { + $updates['resource_limits'] = $resourceLimits; + $updates['resource_status'] = $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE; + + if ($updates !== []) { + $account->update($updates); + $account->refresh(); + } + } else { + $account->forceFill(array_merge($updates, [ + 'resource_limits' => $resourceLimits, + 'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE, + ])); + } + + return $resourceLimits; + } + + public function summarize(HostingAccount $account): array + { + $this->applyDefaultLimits($account); + + return [ + 'resource_status' => $account->resource_status ?: HostingAccount::RESOURCE_STATUS_ACTIVE, + 'uploads_restricted' => $account->uploadsAreRestricted(), + 'disk_usage_percent' => $account->diskUsagePercent(), + 'inode_usage_percent' => $account->inodeUsagePercent(), + 'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0), + 'memory_used_mb' => (int) ($account->memory_used_mb ?? 0), + 'process_count' => (int) ($account->process_count ?? 0), + 'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null, + 'cpu_limit_percent' => (int) ($account->cpu_limit_percent ?? 0), + 'memory_limit_mb' => (int) ($account->memory_limit_mb ?? 0), + 'process_limit' => (int) ($account->process_limit ?? 0), + 'io_limit_mb' => (int) ($account->io_limit_mb ?? 0), + 'inode_limit' => (int) ($account->inode_limit ?? 0), + 'warning_count' => (int) ($account->warning_count ?? 0), + 'is_flagged' => (bool) ($account->is_flagged ?? false), + 'last_usage_sync_at' => $account->last_usage_sync_at, + 'last_warning_at' => $account->last_warning_at, + 'throttled_at' => $account->throttled_at, + ]; + } + + public function process(HostingAccount $account): array + { + $account->loadMissing(['product', 'user', 'latestOrder']); + $this->applyDefaultLimits($account); + + $evaluation = $this->evaluate($account); + $breaches = $evaluation['breaches']; + $warnings = $evaluation['warnings']; + $warningMessages = $evaluation['warning_messages']; + $primaryReason = $evaluation['primary_reason']; + + $this->updateBreachCounters($account, $breaches); + + $account->forceFill([ + 'uploads_restricted' => array_key_exists('inode', $breaches) || $account->inode_count >= (int) $account->inode_limit, + 'is_flagged' => $warnings !== [] || $breaches !== [], + 'metadata' => $account->metadata, // Save CPU history from evaluation + ]); + + if ($warnings !== [] || $breaches !== []) { + $account->forceFill([ + 'warning_count' => (int) $account->warning_count + 1, + 'last_warning_at' => now(), + 'last_resource_breach_at' => now(), + ]); + } + + $account->save(); + $account->refresh(); + + foreach ($warningMessages as $type => $message) { + $severity = array_key_exists($type, $breaches) ? 'critical' : 'warning'; + $alertType = array_key_exists($type, $breaches) + ? HostingAccountAlert::TYPE_LIMIT_EXCEEDED + : HostingAccountAlert::TYPE_WARNING; + + $this->recordAlert($account, $alertType, $severity, $message, $evaluation['usage']); + } + + if ($breaches !== []) { + if ($account->isThrottled()) { + if ($this->shouldAutomaticallySuspendForSustainedBreach($account, $breaches)) { + $this->suspendAccount($account, $primaryReason, false); + } + } else { + $this->throttleAccount($account, $primaryReason, false); + } + } elseif ($warnings !== []) { + $this->notifyAccount($account, 'Hosting usage warning', implode(' ', array_values($warningMessages)), true); + } else { + $this->clearWarningState($account); + } + + if ($breaches === []) { + if ($this->shouldAutomaticallyUnsuspend($account)) { + $this->unsuspendAccount($account, 'Usage returned within allowed thresholds.'); + } elseif ($account->isThrottled() && $account->status === HostingAccount::STATUS_ACTIVE) { + $this->restoreAccount($account, 'Usage returned within allowed thresholds.', false); + } + } + + return $evaluation; + } + + public function throttleAccount(HostingAccount $account, string $reason, bool $manual = true): void + { + $account->loadMissing(['user', 'latestOrder']); + + if ($account->status === HostingAccount::STATUS_SUSPENDED) { + return; + } + + $this->sharedNodeProvider->applyAccountResourceProfile($account, true); + + $metadata = array_merge((array) ($account->metadata ?? []), [ + 'throttle_reason' => $reason, + 'throttle_origin' => $manual ? 'manual' : 'automatic', + 'throttled_at' => now()->toIso8601String(), + ]); + + $account->update([ + 'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED, + 'throttled_at' => now(), + 'metadata' => $metadata, + 'uploads_restricted' => $account->uploadsAreRestricted(), + ]); + + $this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason); + $this->recordAlert($account, HostingAccountAlert::TYPE_THROTTLED, 'critical', $reason, $this->usageSnapshot($account)); + $this->notifyAccount($account, 'Hosting account throttled', $reason, false); + } + + public function restoreAccount(HostingAccount $account, ?string $reason = null, bool $manual = true): void + { + $account->loadMissing(['user', 'latestOrder']); + + if ($account->status === HostingAccount::STATUS_SUSPENDED) { + return; + } + + $this->sharedNodeProvider->applyAccountResourceProfile($account, false); + + $metadata = (array) ($account->metadata ?? []); + $metadata['restored_at'] = now()->toIso8601String(); + $metadata['restore_origin'] = $manual ? 'manual' : 'automatic'; + // Clear CPU history on restore + unset($metadata['cpu_history'], $metadata['cpu_averages']); + + $account->update([ + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'uploads_restricted' => false, + 'is_flagged' => false, + 'cpu_breach_streak' => 0, + 'memory_breach_streak' => 0, + 'process_breach_streak' => 0, + 'io_breach_streak' => 0, + 'inode_breach_streak' => 0, + 'throttled_at' => null, + 'metadata' => $metadata, + ]); + + $this->resolveAlerts($account); + + if ($reason) { + $this->notifyAccount($account, 'Hosting account restored', $reason, false); + } + } + + public function suspendAccount(HostingAccount $account, string $reason, bool $manual = true): void + { + $account->loadMissing(['sites', 'user', 'latestOrder']); + + if ($account->status === HostingAccount::STATUS_SUSPENDED) { + return; + } + + $this->sharedNodeProvider->suspendAccount($account, $reason); + + $metadata = array_merge((array) ($account->metadata ?? []), [ + 'suspension_reason' => $reason, + 'suspension_origin' => $manual ? 'manual' : 'automatic', + 'suspended_at' => now()->toIso8601String(), + ]); + + $account->update([ + 'status' => HostingAccount::STATUS_SUSPENDED, + 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, + 'suspension_reason' => $reason, + 'suspended_at' => now(), + 'is_flagged' => true, + 'metadata' => $metadata, + ]); + + $this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_SUSPENDED, $reason); + $this->recordAlert($account, HostingAccountAlert::TYPE_SUSPENDED, 'critical', $reason, $this->usageSnapshot($account)); + } + + public function unsuspendAccount(HostingAccount $account, ?string $reason = null): void + { + $account->loadMissing(['sites', 'user', 'latestOrder']); + + if ($account->status !== HostingAccount::STATUS_SUSPENDED) { + return; + } + + $this->sharedNodeProvider->unsuspendAccount($account); + $this->sharedNodeProvider->applyAccountResourceProfile($account, false); + + $metadata = (array) ($account->metadata ?? []); + $metadata['unsuspended_at'] = now()->toIso8601String(); + + $account->update([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'suspension_reason' => null, + 'suspended_at' => null, + 'uploads_restricted' => false, + 'metadata' => $metadata, + ]); + + $this->syncLatestOrderState($account, CustomerHostingOrder::STATUS_ACTIVE, $reason); + $this->resolveAlerts($account); + } + + private function evaluate(HostingAccount $account): array + { + $usage = $this->usageSnapshot($account); + $warnings = []; + $breaches = []; + $messages = []; + + if ($usage['disk_usage_percent'] >= self::DISK_WARNING_THRESHOLD) { + $warnings['disk'] = true; + $messages['disk'] = sprintf( + 'Disk usage is at %.1f%% of the allocated quota.', + $usage['disk_usage_percent'] + ); + } + + if ($usage['inode_usage_percent'] >= self::INODE_WARNING_THRESHOLD) { + $warnings['inode'] = true; + $messages['inode'] = sprintf( + 'Inode usage is at %.1f%% of the allowed limit.', + $usage['inode_usage_percent'] + ); + } + + // CPU: Use time-based sustained usage, not instant spikes + if ($account->cpu_limit_percent) { + $cpuEvaluation = $this->evaluateCpuUsage($account, $usage['cpu_usage_percent'], $account->cpu_limit_percent); + if ($cpuEvaluation['is_breach']) { + $breaches['cpu'] = true; + $messages['cpu'] = $cpuEvaluation['message']; + } elseif ($cpuEvaluation['is_warning']) { + $warnings['cpu'] = true; + $messages['cpu'] = $cpuEvaluation['message']; + } + } + + if ($account->memory_limit_mb && $usage['memory_used_mb'] > $account->memory_limit_mb) { + $breaches['memory'] = true; + $messages['memory'] = sprintf( + 'Memory usage reached %d MB against a %d MB limit.', + $usage['memory_used_mb'], + $account->memory_limit_mb + ); + } + + if ($account->process_limit && $usage['process_count'] > $account->process_limit) { + $breaches['process'] = true; + $messages['process'] = sprintf( + 'Process count reached %d against a %d process limit.', + $usage['process_count'], + $account->process_limit + ); + } + + if ($account->io_limit_mb && $usage['io_usage_mb'] !== null && $usage['io_usage_mb'] > $account->io_limit_mb) { + $breaches['io'] = true; + $messages['io'] = sprintf( + 'I/O usage reached %.1f MB against a %d MB limit.', + $usage['io_usage_mb'], + $account->io_limit_mb + ); + } + + if ($account->inode_limit && $usage['inode_count'] > $account->inode_limit) { + $breaches['inode'] = true; + $messages['inode_limit'] = sprintf( + 'Inode count reached %d against a %d inode limit.', + $usage['inode_count'], + $account->inode_limit + ); + } + + $primaryReason = $messages[array_key_first($breaches)] ?? implode(' ', array_values($messages)) ?: 'Resource policy triggered.'; + + return [ + 'usage' => $usage, + 'warnings' => $warnings, + 'breaches' => $breaches, + 'warning_messages' => $messages, + 'primary_reason' => $primaryReason, + ]; + } + + private function updateBreachCounters(HostingAccount $account, array $breaches): void + { + foreach ([ + 'cpu', + 'memory', + 'process', + 'io', + 'inode', + ] as $metric) { + $column = "{$metric}_breach_streak"; + $account->{$column} = array_key_exists($metric, $breaches) + ? ((int) $account->{$column}) + 1 + : 0; + } + } + + private function evaluateCpuUsage(HostingAccount $account, float $currentCpu, int $limit): array + { + $metadata = (array) ($account->metadata ?? []); + $cpuHistory = (array) ($metadata['cpu_history'] ?? []); + + // Add current reading with timestamp + $now = now()->timestamp; + $cpuHistory[] = ['ts' => $now, 'cpu' => $currentCpu]; + + // Keep only last 30 minutes of data (sync runs every 5 min = ~6 readings) + $cutoff = $now - 1800; // 30 minutes + $cpuHistory = array_filter($cpuHistory, fn($r) => $r['ts'] > $cutoff); + $cpuHistory = array_values($cpuHistory); + + // Calculate averages over time windows + $windows = [ + '1m' => 60, // 1 minute + '5m' => 300, // 5 minutes + '15m' => 900, // 15 minutes + ]; + + $averages = []; + foreach ($windows as $name => $seconds) { + $windowCutoff = $now - $seconds; + $windowReadings = array_filter($cpuHistory, fn($r) => $r['ts'] > $windowCutoff); + $count = count($windowReadings); + + if ($count > 0) { + $sum = array_sum(array_column($windowReadings, 'cpu')); + $averages[$name] = $sum / $count; + } else { + $averages[$name] = $currentCpu; + } + } + + // Save updated history + $metadata['cpu_history'] = $cpuHistory; + $metadata['cpu_averages'] = $averages; + $account->metadata = $metadata; + + // Evaluation rules: + // - Warning threshold: 80% of limit, sustained for 5 minutes + // - Breach threshold: 100% of limit, sustained for 10 minutes + // - Extreme breach: 120% of limit, sustained for 5 minutes + + $warningThreshold = $limit * 0.8; + $breachThreshold = $limit; + $extremeThreshold = $limit * 1.2; + + $isWarning = false; + $isBreach = false; + $message = ''; + + // Check for extreme breach (immediate action) + if ($averages['5m'] >= $extremeThreshold) { + $isBreach = true; + $message = sprintf( + 'CPU usage critically high: %.1f%% average over 5 minutes (limit: %d%%).', + $averages['5m'], + $limit + ); + } + // Check for sustained breach (10+ minutes at or above limit) + elseif ($averages['15m'] >= $breachThreshold) { + $isBreach = true; + $message = sprintf( + 'CPU usage sustained above limit: %.1f%% average over 15 minutes (limit: %d%%).', + $averages['15m'], + $limit + ); + } + // Check for sustained warning (5+ minutes at 80%+ of limit) + elseif ($averages['5m'] >= $warningThreshold) { + $isWarning = true; + $message = sprintf( + 'CPU usage elevated: %.1f%% average over 5 minutes (limit: %d%%). Monitor closely.', + $averages['5m'], + $limit + ); + } + + return [ + 'is_breach' => $isBreach, + 'is_warning' => $isWarning, + 'message' => $message, + 'averages' => $averages, + ]; + } + + private function usageSnapshot(HostingAccount $account): array + { + return [ + 'disk_used_bytes' => (int) $account->disk_used_bytes, + 'disk_usage_percent' => $account->diskUsagePercent(), + 'inode_count' => (int) $account->inode_count, + 'inode_usage_percent' => $account->inodeUsagePercent(), + 'cpu_usage_percent' => (float) ($account->cpu_usage_percent ?? 0), + 'memory_used_mb' => (int) ($account->memory_used_mb ?? 0), + 'process_count' => (int) ($account->process_count ?? 0), + 'io_usage_mb' => $account->io_usage_mb !== null ? (float) $account->io_usage_mb : null, + ]; + } + + private function clearWarningState(HostingAccount $account): void + { + $account->update([ + 'is_flagged' => false, + 'uploads_restricted' => false, + 'cpu_breach_streak' => 0, + 'memory_breach_streak' => 0, + 'process_breach_streak' => 0, + 'io_breach_streak' => 0, + 'inode_breach_streak' => 0, + ]); + + $this->resolveAlerts($account); + } + + private function shouldAutomaticallyUnsuspend(HostingAccount $account): bool + { + return $account->status === HostingAccount::STATUS_SUSPENDED + && $account->resource_status === HostingAccount::RESOURCE_STATUS_SUSPENDED + && data_get($account->metadata, 'suspension_origin') === 'automatic'; + } + + private function shouldAutomaticallySuspendForSustainedBreach(HostingAccount $account, array $breaches): bool + { + if (! $account->throttled_at || $account->throttled_at->gt(now()->subHours(self::AUTOMATIC_SUSPENSION_GRACE_HOURS))) { + return false; + } + + foreach (array_keys($breaches) as $metric) { + $column = "{$metric}_breach_streak"; + + if ((int) ($account->{$column} ?? 0) >= self::AUTOMATIC_SUSPENSION_MIN_BREACH_STREAK) { + return true; + } + } + + return false; + } + + private function recordAlert(HostingAccount $account, string $alertType, string $severity, string $message, array $usage): void + { + $existing = $account->alerts() + ->where('alert_type', $alertType) + ->where('is_resolved', false) + ->latest() + ->first(); + + if ($existing) { + $existing->update([ + 'severity' => $severity, + 'message' => $message, + 'resource_usage' => $usage, + ]); + + return; + } + + $account->alerts()->create([ + 'alert_type' => $alertType, + 'severity' => $severity, + 'message' => $message, + 'resource_usage' => $usage, + ]); + } + + private function resolveAlerts(HostingAccount $account): void + { + $account->alerts() + ->unresolved() + ->update([ + 'is_resolved' => true, + 'resolved_at' => now(), + ]); + } + + private function notifyAccount(HostingAccount $account, string $subject, string $message, bool $respectCooldown): void + { + if (! $account->user) { + return; + } + + if ( + $respectCooldown + && (int) $account->warning_count > 1 + && $account->last_warning_at + && $account->last_warning_at->gt(now()->subHours(6)) + ) { + return; + } + + $account->user->notify(new HostingResourceWarningNotification($account, $subject, $message)); + } + + private function syncLatestOrderState(HostingAccount $account, string $status, ?string $reason = null): void + { + $order = $account->latestOrder; + + if (! $order) { + return; + } + + if ($status === CustomerHostingOrder::STATUS_SUSPENDED) { + $order->suspend($reason); + return; + } + + $meta = array_merge((array) ($order->meta ?? []), [ + 'resource_policy_note' => $reason, + ]); + + $order->update([ + 'status' => $status, + 'suspended_at' => $status === CustomerHostingOrder::STATUS_SUSPENDED ? now() : null, + 'meta' => $meta, + ]); + } +} diff --git a/app/Services/Hosting/NodeCapacityService.php b/app/Services/Hosting/NodeCapacityService.php new file mode 100644 index 0000000..7e88df6 --- /dev/null +++ b/app/Services/Hosting/NodeCapacityService.php @@ -0,0 +1,376 @@ +whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL]) + ->where('type', HostingNode::TYPE_SHARED) + ->get(); + + foreach ($nodes as $node) { + $alert = $this->checkNode($node); + + if ($alert) { + $alerts[] = $alert; + } + } + + return $alerts; + } + + public function checkNode(HostingNode $node): ?NodeCapacityAlert + { + $node = $this->refreshNodeResourceTotals($node); + $capacityPercent = $this->nodePressurePercent($node); + + $existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id) + ->where('is_resolved', false) + ->whereIn('alert_type', [ + NodeCapacityAlert::TYPE_CAPACITY_WARNING, + NodeCapacityAlert::TYPE_CAPACITY_CRITICAL, + NodeCapacityAlert::TYPE_NEEDS_EXPANSION, + ]) + ->first(); + + if ($capacityPercent >= self::CRITICAL_THRESHOLD) { + return $this->createOrUpdateAlert( + $node, + NodeCapacityAlert::TYPE_CAPACITY_CRITICAL, + $capacityPercent, + sprintf( + 'Node %s is at %.1f%% pressure (%s). Immediate action required.', + $node->name, + $capacityPercent, + $this->dominantPressureLabel($node) + ), + $existingAlert + ); + } + + if ($capacityPercent >= self::WARNING_THRESHOLD) { + return $this->createOrUpdateAlert( + $node, + NodeCapacityAlert::TYPE_CAPACITY_WARNING, + $capacityPercent, + sprintf( + 'Node %s is at %.1f%% pressure (%s). Capacity is nearing exhaustion.', + $node->name, + $capacityPercent, + $this->dominantPressureLabel($node) + ), + $existingAlert + ); + } + + if ($existingAlert) { + $existingAlert->update([ + 'is_resolved' => true, + 'resolved_at' => now(), + 'resolution_notes' => 'Auto-resolved: Node pressure dropped below warning threshold.', + ]); + } + + return null; + } + + public function canProvision(HostingNode|int $node, int $requestedDiskGb): bool + { + $node = $node instanceof HostingNode ? $node : HostingNode::query()->findOrFail($node); + $node = $this->refreshNodeResourceTotals($node); + + return $node->canProvision($requestedDiskGb); + } + + public function checkCapacityForProduct(HostingProduct $product): array + { + $requestedDiskGb = (int) ($product->disk_gb ?? 0); + $segment = $product->preferredNodeSegment(); + $nodes = $this->preparedCandidateNodes($segment) + ->map(fn (HostingNode $node): array => $this->nodeSnapshot($node, $requestedDiskGb, $segment)) + ->values(); + + $selected = $this->findAvailableNodeForProduct($product); + + return [ + 'available' => $selected !== null, + 'requested_disk_gb' => $requestedDiskGb, + 'segment' => $segment, + 'selected_node_id' => $selected?->id, + 'selected_node_name' => $selected?->name, + 'nodes' => $nodes, + ]; + } + + public function findAvailableNode(int $requestedDiskGb = 0, ?string $segment = null): ?HostingNode + { + $primaries = $this->preparedCandidateNodes($segment) + ->filter(fn (HostingNode $node) => $node->isPrimary()); + + $bestMember = null; + $bestLoad = PHP_FLOAT_MAX; + + foreach ($primaries as $primary) { + $member = $this->nodePool->findProvisioningMember($primary, $requestedDiskGb, $segment); + if (! $member) { + continue; + } + + if ($this->isLocalNode($member) && $member->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) { + continue; + } + + $load = (float) $member->current_load_percent; + if ($load < $bestLoad) { + $bestLoad = $load; + $bestMember = $member; + } + } + + if ($bestMember) { + return $bestMember; + } + + foreach ($this->preparedCandidateNodes($segment) as $node) { + if (! $node->canProvision($requestedDiskGb, $segment)) { + continue; + } + + if ($this->isLocalNode($node) && $node->accountCapacityPercent() >= self::LOCAL_NODE_LIMIT) { + continue; + } + + return $node; + } + + return null; + } + + public function findAvailableNodeForProduct(HostingProduct $product): ?HostingNode + { + return $this->findAvailableNode( + (int) ($product->disk_gb ?? 0), + $product->preferredNodeSegment() + ); + } + + public function needsNewNode(): bool + { + return $this->findAvailableNode() === null; + } + + public function getCapacitySummary(): array + { + $nodes = HostingNode::query() + ->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL]) + ->where('type', HostingNode::TYPE_SHARED) + ->get() + ->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node)); + + $totalDisk = $nodes->sum('disk_gb'); + $totalLogicalCapacity = $nodes->sum(fn (HostingNode $node): float => $node->logicalCapacityGb()); + $totalAllocated = $nodes->sum('allocated_disk_gb'); + $totalUsed = $nodes->sum('used_disk_gb'); + $totalAccountCapacity = $nodes->sum('max_accounts'); + $totalAccounts = $nodes->sum('current_accounts'); + + return [ + 'total_nodes' => $nodes->count(), + 'total_capacity' => $totalAccountCapacity, + 'total_used' => $totalAccounts, + 'available_slots' => max($totalAccountCapacity - $totalAccounts, 0), + 'overall_percent' => $totalAccountCapacity > 0 ? round(($totalAccounts / $totalAccountCapacity) * 100, 1) : 0, + 'physical_disk_gb' => $totalDisk, + 'logical_disk_gb' => round($totalLogicalCapacity, 2), + 'allocated_disk_gb' => $totalAllocated, + 'used_disk_gb' => $totalUsed, + 'logical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0, + 'physical_disk_percent' => $totalDisk > 0 ? round(($totalUsed / $totalDisk) * 100, 1) : 0, + 'needs_expansion' => $this->needsNewNode(), + 'unresolved_alerts' => NodeCapacityAlert::unresolved()->count(), + ]; + } + + public function refreshNodeResourceTotals(HostingNode $node): HostingNode + { + $aggregate = HostingAccount::query() + ->where('hosting_node_id', $node->id) + ->where('type', HostingAccount::TYPE_SHARED) + ->selectRaw('COUNT(*) as account_count') + ->selectRaw('COALESCE(SUM(allocated_disk_gb), 0) as allocated_disk_gb') + ->selectRaw('COALESCE(SUM(disk_used_bytes), 0) as used_disk_bytes') + ->first(); + + $currentAccounts = (int) ($aggregate->account_count ?? 0); + $allocatedDiskGb = (int) ($aggregate->allocated_disk_gb ?? 0); + $usedDiskGb = (int) ceil(((int) ($aggregate->used_disk_bytes ?? 0)) / self::BYTES_PER_GB); + $logicalCapacity = $node->disk_gb && $node->disk_gb > 0 + ? $node->disk_gb * max((float) ($node->oversell_ratio ?: 1), 1) + : 0; + $physicalPercent = $node->disk_gb && $node->disk_gb > 0 ? ($usedDiskGb / $node->disk_gb) * 100 : 0; + $accountPercent = $node->max_accounts && $node->max_accounts > 0 ? ($currentAccounts / $node->max_accounts) * 100 : 0; + $currentLoadPercent = round(max($physicalPercent, $accountPercent), 2); + + $updates = [ + 'current_accounts' => $currentAccounts, + 'allocated_disk_gb' => $allocatedDiskGb, + 'used_disk_gb' => $usedDiskGb, + 'current_load_percent' => $currentLoadPercent, + ]; + + if (in_array($node->status, [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL], true)) { + $updates['status'] = $this->shouldMarkFull($node, $currentAccounts, $usedDiskGb) + ? HostingNode::STATUS_FULL + : HostingNode::STATUS_ACTIVE; + } + + $node->forceFill($updates); + + if ($node->exists && $node->isDirty()) { + $node->save(); + } + + return $node->refresh(); + } + + private function candidateNodes(?string $segment) + { + return HostingNode::query() + ->whereIn('status', [HostingNode::STATUS_ACTIVE, HostingNode::STATUS_FULL]) + ->where('type', HostingNode::TYPE_SHARED) + ->when($segment, function ($query) use ($segment) { + $query->whereIn('segment', [$segment, HostingNode::SEGMENT_GENERAL]); + }) + ->get(); + } + + private function preparedCandidateNodes(?string $segment) + { + return $this->candidateNodes($segment) + ->map(fn (HostingNode $node): HostingNode => $this->refreshNodeResourceTotals($node)) + ->sortBy(fn (HostingNode $node) => sprintf( + '%012.2f-%012d-%012d', + (float) $node->current_load_percent, + $node->allocated_disk_gb, + $node->current_accounts + )) + ->values(); + } + + private function shouldMarkFull(HostingNode $node, int $currentAccounts, int $usedDiskGb): bool + { + if ($node->max_accounts && $currentAccounts >= $node->max_accounts) { + return true; + } + + if ($node->disk_gb && $node->disk_gb > 0) { + return $usedDiskGb >= $node->disk_gb; + } + + return false; + } + + private function nodePressurePercent(HostingNode $node): float + { + return round(max( + $node->physicalDiskPercent(), + $node->accountCapacityPercent() + ), 2); + } + + private function dominantPressureLabel(HostingNode $node): string + { + $pressures = [ + 'disk usage' => $node->physicalDiskPercent(), + 'account slots' => $node->accountCapacityPercent(), + ]; + + arsort($pressures); + + $label = array_key_first($pressures); + + return sprintf('%s %.1f%%', $label, $pressures[$label] ?? 0); + } + + private function nodeSnapshot(HostingNode $node, int $requestedDiskGb, ?string $segment = null): array + { + return [ + 'id' => $node->id, + 'name' => $node->name, + 'segment' => $node->segment, + 'can_provision' => $node->canProvision($requestedDiskGb, $segment), + 'requested_disk_gb' => $requestedDiskGb, + 'physical_disk_gb' => $node->disk_gb, + 'logical_disk_gb' => $node->logicalCapacityGb(), + 'allocated_disk_gb' => $node->allocated_disk_gb, + 'used_disk_gb' => $node->used_disk_gb, + 'available_logical_disk_gb' => $node->availableLogicalDiskGb(), + 'oversell_ratio' => (float) $node->oversell_ratio, + 'current_accounts' => $node->current_accounts, + 'max_accounts' => $node->max_accounts, + 'logical_capacity_percent' => $node->logicalCapacityPercent(), + 'physical_disk_percent' => $node->physicalDiskPercent(), + 'account_capacity_percent' => $node->accountCapacityPercent(), + 'current_load_percent' => (float) $node->current_load_percent, + ]; + } + + private function isLocalNode(HostingNode $node): bool + { + return $node->provider === HostingNode::PROVIDER_LOCAL + || $node->ip_address === '127.0.0.1' + || $node->ip_address === 'localhost'; + } + + private function createOrUpdateAlert( + HostingNode $node, + string $type, + float $capacityPercent, + string $message, + ?NodeCapacityAlert $existingAlert + ): NodeCapacityAlert { + $data = [ + 'hosting_node_id' => $node->id, + 'alert_type' => $type, + 'current_accounts' => $node->current_accounts, + 'max_accounts' => $node->max_accounts ?? 0, + 'capacity_percent' => $capacityPercent, + 'resource_usage' => [ + 'logical_capacity_percent' => $node->logicalCapacityPercent(), + 'physical_disk_percent' => $node->physicalDiskPercent(), + 'account_capacity_percent' => $node->accountCapacityPercent(), + 'allocated_disk_gb' => $node->allocated_disk_gb, + 'used_disk_gb' => $node->used_disk_gb, + 'logical_disk_gb' => $node->logicalCapacityGb(), + ], + 'message' => $message, + ]; + + if ($existingAlert) { + $existingAlert->update($data); + + return $existingAlert; + } + + return NodeCapacityAlert::create($data); + } +} diff --git a/app/Services/Hosting/NodeMonitoringService.php b/app/Services/Hosting/NodeMonitoringService.php new file mode 100644 index 0000000..3019ce6 --- /dev/null +++ b/app/Services/Hosting/NodeMonitoringService.php @@ -0,0 +1,497 @@ +get(); + + foreach ($nodes as $node) { + $results[$node->id] = $this->checkNode($node); + } + + return $results; + } + + public function checkNode(HostingNode $node): array + { + $startTime = microtime(true); + + try { + $ssh = $this->connect($node); + + $metrics = $this->collectMetrics($ssh, $node); + $metrics['response_time_ms'] = round((microtime(true) - $startTime) * 1000); + $metrics['checked_at'] = now()->toIso8601String(); + + $healthStatus = $this->evaluateHealth($metrics, $node); + $metrics['health'] = $healthStatus; + + $node->update([ + 'health_status' => $metrics, + 'last_health_check_at' => now(), + ]); + + $this->processAlerts($node, $metrics, $healthStatus); + + Log::info("Node health check completed", [ + 'node_id' => $node->id, + 'node_name' => $node->name, + 'health' => $healthStatus, + ]); + + return $metrics; + + } catch (\Exception $e) { + $metrics = [ + 'health' => self::HEALTH_UNREACHABLE, + 'error' => $e->getMessage(), + 'checked_at' => now()->toIso8601String(), + 'response_time_ms' => round((microtime(true) - $startTime) * 1000), + ]; + + $node->update([ + 'health_status' => $metrics, + 'last_health_check_at' => now(), + ]); + + $this->createUnreachableAlert($node, $e->getMessage()); + + Log::error("Node health check failed", [ + 'node_id' => $node->id, + 'node_name' => $node->name, + 'error' => $e->getMessage(), + ]); + + return $metrics; + } finally { + $this->disconnect(); + } + } + + private function isLocal(HostingNode $node): bool + { + return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost') + && blank($node->ssh_private_key); + } + + private function connect(HostingNode $node): ?SSH2 + { + if ($this->isLocal($node)) { + $this->isLocalNode = true; + return null; + } + + $this->isLocalNode = false; + + // For local nodes with SSH keys, connect to 127.0.0.1 + $host = $this->isLocalIp($node) ? '127.0.0.1' : $node->ip_address; + $this->ssh = new SSH2($host, (int) ($node->ssh_port ?: 22)); + $this->ssh->setTimeout(30); + + // Load the private key properly for phpseclib3 + $credential = $node->ssh_private_key; + if (str_contains($credential, '-----BEGIN') || str_contains($credential, 'PRIVATE KEY')) { + try { + $credential = PublicKeyLoader::load($credential); + } catch (\Throwable $e) { + throw new \RuntimeException("Invalid SSH key for node {$node->hostname}: " . $e->getMessage()); + } + } + + if (!$this->ssh->login('root', $credential)) { + throw new \RuntimeException("SSH authentication failed for node: {$node->hostname}"); + } + + return $this->ssh; + } + + private function isLocalIp(HostingNode $node): bool + { + return $node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost'; + } + + private function disconnect(): void + { + if ($this->ssh) { + $this->ssh->disconnect(); + $this->ssh = null; + } + + $this->isLocalNode = false; + } + + private function exec(?SSH2 $ssh, string $command): string + { + if ($this->isLocalNode) { + $result = Process::run($command); + return trim($result->output() . $result->errorOutput()); + } + + return trim($ssh->exec($command)); + } + + private function collectMetrics(?SSH2 $ssh, HostingNode $node): array + { + return [ + 'cpu' => $this->getCpuMetrics($ssh), + 'memory' => $this->getMemoryMetrics($ssh), + 'disk' => $this->getDiskMetrics($ssh), + 'load' => $this->getLoadMetrics($ssh, $node), + 'uptime' => $this->getUptime($ssh), + 'processes' => $this->getProcessCount($ssh), + 'services' => $this->checkServices($ssh), + 'network' => $this->getNetworkMetrics($ssh), + ]; + } + + private function getCpuMetrics(?SSH2 $ssh): array + { + $output = $this->exec($ssh, "top -bn1 | grep 'Cpu(s)' | awk '{print \$2 + \$4}'"); + $usagePercent = is_numeric($output) ? (float) $output : 0; + + $coresOutput = $this->exec($ssh, "nproc"); + $cores = is_numeric($coresOutput) ? (int) $coresOutput : 1; + + return [ + 'usage_percent' => round($usagePercent, 1), + 'cores' => $cores, + ]; + } + + private function getMemoryMetrics(?SSH2 $ssh): array + { + $output = $this->exec($ssh, "free -b | grep Mem"); + $parts = preg_split('/\s+/', trim($output)); + + $total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0; + $used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0; + $free = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0; + $available = isset($parts[6]) && is_numeric($parts[6]) ? (int) $parts[6] : $free; + + $usagePercent = $total > 0 ? (($total - $available) / $total) * 100 : 0; + + return [ + 'total_bytes' => $total, + 'used_bytes' => $used, + 'available_bytes' => $available, + 'usage_percent' => round($usagePercent, 1), + ]; + } + + private function getDiskMetrics(?SSH2 $ssh): array + { + $output = $this->exec($ssh, "df -B1 / | tail -1"); + $parts = preg_split('/\s+/', trim($output)); + + $total = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0; + $used = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : 0; + $available = isset($parts[3]) && is_numeric($parts[3]) ? (int) $parts[3] : 0; + $usagePercent = isset($parts[4]) ? (float) rtrim($parts[4], '%') : 0; + + $homeOutput = $this->exec($ssh, "df -B1 /home 2>/dev/null | tail -1"); + $homeParts = preg_split('/\s+/', trim($homeOutput)); + $homeUsagePercent = isset($homeParts[4]) ? (float) rtrim($homeParts[4], '%') : $usagePercent; + + return [ + 'root' => [ + 'total_bytes' => $total, + 'used_bytes' => $used, + 'available_bytes' => $available, + 'usage_percent' => $usagePercent, + ], + 'home_usage_percent' => $homeUsagePercent, + ]; + } + + private function getLoadMetrics(?SSH2 $ssh, HostingNode $node): array + { + $output = $this->exec($ssh, "cat /proc/loadavg"); + $parts = explode(' ', $output); + + $load1 = isset($parts[0]) && is_numeric($parts[0]) ? (float) $parts[0] : 0; + $load5 = isset($parts[1]) && is_numeric($parts[1]) ? (float) $parts[1] : 0; + $load15 = isset($parts[2]) && is_numeric($parts[2]) ? (float) $parts[2] : 0; + + $cores = $node->cpu_cores ?: 1; + $loadPerCore = $load1 / $cores; + + return [ + 'load_1' => $load1, + 'load_5' => $load5, + 'load_15' => $load15, + 'load_per_core' => round($loadPerCore, 2), + 'cores' => $cores, + ]; + } + + private function getUptime(?SSH2 $ssh): array + { + $output = $this->exec($ssh, "cat /proc/uptime | awk '{print \$1}'"); + $uptimeSeconds = is_numeric($output) ? (int) $output : 0; + + $days = floor($uptimeSeconds / 86400); + $hours = floor(($uptimeSeconds % 86400) / 3600); + $minutes = floor(($uptimeSeconds % 3600) / 60); + + return [ + 'seconds' => $uptimeSeconds, + 'formatted' => "{$days}d {$hours}h {$minutes}m", + ]; + } + + private function getProcessCount(?SSH2 $ssh): array + { + $total = (int) $this->exec($ssh, "ps aux | wc -l") - 1; + $running = (int) $this->exec($ssh, "ps aux | grep -c ' R'"); + + return [ + 'total' => $total, + 'running' => $running, + ]; + } + + private function checkServices(?SSH2 $ssh): array + { + // Check for PHP-FPM with dynamic version detection + $phpVersion = $this->exec($ssh, "php -v | head -n1 | cut -d' ' -f2 | cut -d'.' -f1,2"); + $phpVersion = preg_match('/^\d+\.\d+$/', $phpVersion) ? $phpVersion : '8.2'; + $phpFpmService = "php{$phpVersion}-fpm"; + + $services = ['nginx', $phpFpmService, 'mysql', 'ssh']; + $status = []; + + foreach ($services as $service) { + $output = $this->exec($ssh, "systemctl is-active {$service} 2>/dev/null || echo 'inactive'"); + $status[$service] = $output === 'active'; + } + + return $status; + } + + private function getNetworkMetrics(?SSH2 $ssh): array + { + $output = $this->exec($ssh, "cat /proc/net/dev | grep -E 'eth0|ens|enp' | head -1"); + $parts = preg_split('/\s+/', trim($output)); + + $rxBytes = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0; + $txBytes = isset($parts[9]) && is_numeric($parts[9]) ? (int) $parts[9] : 0; + + $connections = (int) $this->exec($ssh, "ss -tun | wc -l") - 1; + + return [ + 'rx_bytes_total' => $rxBytes, + 'tx_bytes_total' => $txBytes, + 'active_connections' => max(0, $connections), + ]; + } + + private function evaluateHealth(array $metrics, HostingNode $node): string + { + $cpuUsage = $metrics['cpu']['usage_percent'] ?? 0; + $ramUsage = $metrics['memory']['usage_percent'] ?? 0; + $diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0; + $loadPerCore = $metrics['load']['load_per_core'] ?? 0; + + $services = $metrics['services'] ?? []; + $criticalServices = ['nginx', 'php8.2-fpm']; + foreach ($criticalServices as $service) { + if (isset($services[$service]) && !$services[$service]) { + return self::HEALTH_CRITICAL; + } + } + + if ( + $cpuUsage >= self::CPU_CRITICAL_THRESHOLD || + $ramUsage >= self::RAM_CRITICAL_THRESHOLD || + $diskUsage >= self::DISK_CRITICAL_THRESHOLD || + $loadPerCore >= self::LOAD_CRITICAL_MULTIPLIER + ) { + return self::HEALTH_CRITICAL; + } + + if ( + $cpuUsage >= self::CPU_WARNING_THRESHOLD || + $ramUsage >= self::RAM_WARNING_THRESHOLD || + $diskUsage >= self::DISK_WARNING_THRESHOLD || + $loadPerCore >= self::LOAD_WARNING_MULTIPLIER + ) { + return self::HEALTH_WARNING; + } + + return self::HEALTH_OK; + } + + private function processAlerts(HostingNode $node, array $metrics, string $healthStatus): void + { + $existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id) + ->where('is_resolved', false) + ->whereIn('alert_type', ['resource_high', 'service_down']) + ->first(); + + if ($healthStatus === self::HEALTH_OK) { + if ($existingAlert) { + $existingAlert->update([ + 'is_resolved' => true, + 'resolved_at' => now(), + 'resolution_notes' => 'Auto-resolved: Node health returned to normal.', + ]); + } + return; + } + + $issues = $this->identifyIssues($metrics); + + if (empty($issues)) { + return; + } + + $message = "Node {$node->name} health issues: " . implode(', ', $issues); + + if ($existingAlert) { + $existingAlert->update([ + 'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning', + 'resource_usage' => $metrics, + 'message' => $message, + ]); + } else { + NodeCapacityAlert::create([ + 'hosting_node_id' => $node->id, + 'alert_type' => $healthStatus === self::HEALTH_CRITICAL ? 'resource_high' : 'capacity_warning', + 'current_accounts' => $node->current_accounts, + 'max_accounts' => $node->max_accounts ?? 0, + 'capacity_percent' => $node->max_accounts > 0 + ? ($node->current_accounts / $node->max_accounts) * 100 + : 0, + 'resource_usage' => $metrics, + 'message' => $message, + ]); + } + } + + private function identifyIssues(array $metrics): array + { + $issues = []; + + $cpuUsage = $metrics['cpu']['usage_percent'] ?? 0; + if ($cpuUsage >= self::CPU_WARNING_THRESHOLD) { + $issues[] = "CPU at {$cpuUsage}%"; + } + + $ramUsage = $metrics['memory']['usage_percent'] ?? 0; + if ($ramUsage >= self::RAM_WARNING_THRESHOLD) { + $issues[] = "RAM at {$ramUsage}%"; + } + + $diskUsage = $metrics['disk']['root']['usage_percent'] ?? 0; + if ($diskUsage >= self::DISK_WARNING_THRESHOLD) { + $issues[] = "Disk at {$diskUsage}%"; + } + + $loadPerCore = $metrics['load']['load_per_core'] ?? 0; + if ($loadPerCore >= self::LOAD_WARNING_MULTIPLIER) { + $issues[] = "Load per core: {$loadPerCore}"; + } + + $services = $metrics['services'] ?? []; + foreach ($services as $service => $running) { + if (!$running) { + $issues[] = "{$service} is down"; + } + } + + return $issues; + } + + private function createUnreachableAlert(HostingNode $node, string $error): void + { + $existingAlert = NodeCapacityAlert::where('hosting_node_id', $node->id) + ->where('is_resolved', false) + ->where('alert_type', 'resource_high') + ->first(); + + $message = "Node {$node->name} is unreachable: {$error}"; + + if ($existingAlert) { + $existingAlert->update([ + 'message' => $message, + 'resource_usage' => ['error' => $error], + ]); + } else { + NodeCapacityAlert::create([ + 'hosting_node_id' => $node->id, + 'alert_type' => 'resource_high', + 'current_accounts' => $node->current_accounts, + 'max_accounts' => $node->max_accounts ?? 0, + 'capacity_percent' => $node->max_accounts > 0 + ? ($node->current_accounts / $node->max_accounts) * 100 + : 0, + 'resource_usage' => ['error' => $error], + 'message' => $message, + ]); + } + } + + public function getNodeSummary(HostingNode $node): array + { + $healthStatus = $node->health_status ?? []; + + return [ + 'id' => $node->id, + 'name' => $node->name, + 'hostname' => $node->hostname, + 'ip_address' => $node->ip_address, + 'status' => $node->status, + 'health' => $healthStatus['health'] ?? 'unknown', + 'last_check' => $node->last_health_check_at?->diffForHumans() ?? 'Never', + 'cpu_usage' => $healthStatus['cpu']['usage_percent'] ?? null, + 'ram_usage' => $healthStatus['memory']['usage_percent'] ?? null, + 'disk_usage' => $healthStatus['disk']['root']['usage_percent'] ?? null, + 'load' => $healthStatus['load']['load_1'] ?? null, + 'uptime' => $healthStatus['uptime']['formatted'] ?? null, + 'logical_capacity_percent' => $node->logicalCapacityPercent(), + 'current_load_percent' => (float) $node->current_load_percent, + 'allocated_disk_gb' => $node->allocated_disk_gb, + 'logical_disk_gb' => $node->logicalCapacityGb(), + 'accounts' => [ + 'current' => $node->current_accounts, + 'max' => $node->max_accounts, + 'percent' => $node->max_accounts > 0 + ? round(($node->current_accounts / $node->max_accounts) * 100, 1) + : 0, + ], + ]; + } +} diff --git a/app/Services/Hosting/PanelRuntimeResolver.php b/app/Services/Hosting/PanelRuntimeResolver.php new file mode 100644 index 0000000..7f75538 --- /dev/null +++ b/app/Services/Hosting/PanelRuntimeResolver.php @@ -0,0 +1,30 @@ +sharedHostingRuntime; + } + + if ($subject instanceof CustomerHostingOrder) { + throw new RuntimeException('Server-backed hosting panel runtime is not enabled yet for this order.'); + } + + throw new InvalidArgumentException('Unsupported panel runtime subject.'); + } +} diff --git a/app/Services/Hosting/PanelRuntimes/ServerAgentPanelRuntime.php b/app/Services/Hosting/PanelRuntimes/ServerAgentPanelRuntime.php new file mode 100644 index 0000000..e93bf1a --- /dev/null +++ b/app/Services/Hosting/PanelRuntimes/ServerAgentPanelRuntime.php @@ -0,0 +1,140 @@ +meta, 'server_panel_runtime.managed_stack_candidate', false); + } + + public function ensureAgent(CustomerHostingOrder $order): ServerAgent + { + if (! $this->supports($order)) { + throw new RuntimeException('This server image is not enabled for agent-backed Ladill hosting tasks.'); + } + + return $this->bootstrap->ensureBootstrap($order)['agent']; + } + + public function queueDomainTask(CustomerHostingOrder $order, string $domain, ?string $documentRoot = null, ?string $phpVersion = null): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_DOMAIN_ADD, array_filter([ + 'domain' => strtolower($domain), + 'document_root' => $documentRoot, + 'php_version' => $phpVersion, + ], static fn ($value): bool => $value !== null && $value !== '')); + } + + public function queueSslTask(CustomerHostingOrder $order, string $domain): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_SSL_REQUEST, [ + 'domain' => strtolower($domain), + ]); + } + + public function queueDatabaseTask(CustomerHostingOrder $order, string $name): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_DATABASE_CREATE, [ + 'name' => Str::lower($name), + ]); + } + + public function queueSiteDeleteTask(CustomerHostingOrder $order, string $domain): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_SITE_DELETE, [ + 'domain' => strtolower($domain), + ]); + } + + public function queuePhpTask(CustomerHostingOrder $order, string $domain, string $phpVersion): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_PHP_CONFIGURE, [ + 'domain' => strtolower($domain), + 'php_version' => $phpVersion, + ]); + } + + public function queueCronTask( + CustomerHostingOrder $order, + string $operation, + ?string $name = null, + ?string $expression = null, + ?string $command = null, + ?string $user = null, + ): ServerTask { + $taskType = match ($operation) { + 'list' => ServerTask::TYPE_CRON_LIST, + 'upsert' => ServerTask::TYPE_CRON_UPSERT, + 'remove' => ServerTask::TYPE_CRON_REMOVE, + default => throw new RuntimeException('Unsupported cron operation.'), + }; + + return $this->queueTask($order, $taskType, array_filter([ + 'name' => $name, + 'expression' => $expression, + 'command' => $command, + 'user' => $user, + ], static fn ($value): bool => $value !== null && $value !== '')); + } + + public function queueLogTask(CustomerHostingOrder $order, string $target, ?string $domain = null, int $lines = 120): ServerTask + { + return $this->queueTask($order, ServerTask::TYPE_LOG_READ, [ + 'target' => $target, + 'domain' => $domain ? strtolower($domain) : null, + 'lines' => max(20, min(500, $lines)), + ]); + } + + public function queueFileTask(CustomerHostingOrder $order, string $operation, string $path, ?string $content = null): ServerTask + { + $taskType = match ($operation) { + 'list' => ServerTask::TYPE_FILE_LIST, + 'read' => ServerTask::TYPE_FILE_READ, + 'write' => ServerTask::TYPE_FILE_WRITE, + default => throw new RuntimeException('Unsupported file operation.'), + }; + + return $this->queueTask($order, $taskType, array_filter([ + 'path' => $path, + 'content' => $content, + ], static fn ($value): bool => $value !== null)); + } + + private function queueTask(CustomerHostingOrder $order, string $type, array $payload): ServerTask + { + $agent = $this->ensureAgent($order); + + return ServerTask::create([ + 'customer_hosting_order_id' => $order->id, + 'server_agent_id' => $agent->id, + 'type' => $type, + 'status' => ServerTask::STATUS_QUEUED, + 'payload' => $payload, + 'progress' => 0, + 'max_attempts' => 3, + 'retry_backoff_seconds' => 60, + 'stale_after_seconds' => 120, + 'queued_at' => now(), + 'available_at' => now(), + ]); + } +} diff --git a/app/Services/Hosting/PanelRuntimes/SharedHostingPanelRuntime.php b/app/Services/Hosting/PanelRuntimes/SharedHostingPanelRuntime.php new file mode 100644 index 0000000..06686fa --- /dev/null +++ b/app/Services/Hosting/PanelRuntimes/SharedHostingPanelRuntime.php @@ -0,0 +1,149 @@ +provider->executeCommand($account, $command, $timeout); + } + + public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array + { + return $this->provider->executeTerminalCommand($account, $command, $workingDirectory, $timeout); + } + + public function executeCommandAsRoot(HostingAccount $account, string $command): array + { + return $this->provider->executeCommandAsRoot($account, $command); + } + + public function getUsageStats(HostingAccount $account): array + { + return $this->provider->getUsageStats($account); + } + + public function changePassword(HostingAccount $account, string $newPassword): bool + { + return $this->provider->changePassword($account, $newPassword); + } + + public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void + { + $this->provider->installTeamMemberSshKey($account, $marker, $publicKey); + } + + public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void + { + $this->provider->removeTeamMemberSshKey($account, $marker); + } + + public function createDatabase(HostedDatabase $database, string $password): array + { + return $this->provider->createDatabase($database, $password); + } + + public function deleteDatabase(HostedDatabase $database): bool + { + return $this->provider->deleteDatabase($database); + } + + public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool + { + return $this->provider->changeDatabasePassword($database, $newPassword); + } + + public function addSite(HostedSite $site): array + { + return $this->provider->addSite($site); + } + + public function removeSite(HostedSite $site): bool + { + return $this->provider->removeSite($site); + } + + public function requestLetsEncryptCertificate(HostedSite $site): bool + { + return $this->provider->requestLetsEncryptCertificate($site); + } + + public function ensurePhpFpmPool(HostingAccount $account): void + { + $this->provider->ensurePhpFpmPool($account); + } + + public function setPhpVersion(HostedSite $site, string $version): bool + { + return $this->provider->setPhpVersion($site, $version); + } + + public function changeAccountPhpVersion(HostingAccount $account, string $version): bool + { + return $this->provider->changeAccountPhpVersion($account, $version); + } + + public function prepareDirectoryForUser(HostingAccount $account, string $path): void + { + $this->provider->prepareDirectoryForUser($account, $path); + } + + public function setWebServerGroupOwnership(HostingAccount $account, string $path): void + { + $this->provider->setWebServerGroupOwnership($account, $path); + } + + public function removeAppService(HostedSite $site): bool + { + return $this->provider->removeAppService($site); + } + + public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void + { + $this->provider->restoreDefaultSiteConfig($account, $site, $docRoot); + } +} diff --git a/app/Services/Hosting/Providers/ContaboProvider.php b/app/Services/Hosting/Providers/ContaboProvider.php new file mode 100644 index 0000000..beeca45 --- /dev/null +++ b/app/Services/Hosting/Providers/ContaboProvider.php @@ -0,0 +1,553 @@ +clientId = config('hosting.contabo.client_id'); + $this->clientSecret = config('hosting.contabo.client_secret'); + $this->apiUser = config('hosting.contabo.api_user'); + $this->apiPassword = config('hosting.contabo.api_password'); + $this->productCatalogEndpoint = config('hosting.contabo.product_catalog_endpoint'); + } + + private function getAccessToken(): string + { + return Cache::remember('contabo_access_token', 290, function () { + $response = Http::asForm()->post($this->authUrl, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'username' => $this->apiUser, + 'password' => $this->apiPassword, + 'grant_type' => 'password', + ]); + + if (!$response->successful()) { + Log::error('Contabo auth failed', ['response' => $response->body()]); + throw new \RuntimeException('Failed to authenticate with Contabo API'); + } + + return $response->json('access_token'); + }); + } + + private function client(): PendingRequest + { + return Http::withToken($this->getAccessToken()) + ->baseUrl($this->baseUrl) + ->withHeaders([ + 'x-request-id' => Str::uuid()->toString(), + ]) + ->timeout(60); + } + + public function createInstance(array $config): array + { + $payload = [ + 'productId' => $config['product_id'], // e.g., V45 + 'region' => $config['region'] ?? 'EU', + 'displayName' => $config['display_name'] ?? $config['name'] ?? 'ladill-vps-' . uniqid(), + 'period' => (int) ($config['period'] ?? 1), + ]; + + if (!empty($config['image_id'])) { + $payload['imageId'] = $config['image_id']; + } + + if (!empty($config['ssh_keys'])) { + $payload['sshKeys'] = $config['ssh_keys']; + } + + if (!empty($config['root_password_secret_id'])) { + $payload['rootPassword'] = (int) $config['root_password_secret_id']; + } elseif (!empty($config['root_password'])) { + $payload['rootPassword'] = $this->createPasswordSecret( + (string) ($config['display_name'] ?? $config['name'] ?? 'server-password'), + (string) $config['root_password'] + ); + } + + if (!empty($config['user_data'])) { + $payload['userData'] = base64_encode($config['user_data']); + } + + if (!empty($config['license'])) { + $payload['license'] = $config['license']; + } + + if (!empty($config['default_user'])) { + $payload['defaultUser'] = $config['default_user']; + } + + if (!empty($config['application_id'])) { + $payload['applicationId'] = $config['application_id']; + } + + if (!empty($config['add_ons'])) { + $payload['addOns'] = $config['add_ons']; + } + + $response = $this->client()->post('/compute/instances', $payload); + + if (! $response->successful()) { + Log::error('Contabo create instance failed', [ + 'payload' => $payload, + 'status' => $response->status(), + 'response' => $response->body(), + ]); + + throw ContaboApiException::fromResponse($response->status(), (string) $response->body()); + } + + $data = $response->json('data.0'); + + return [ + 'instance_id' => $data['instanceId'], + 'name' => $data['displayName'], + 'ip_address' => $data['ipConfig']['v4']['ip'] ?? null, + 'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null, + 'status' => $this->mapStatus($data['status']), + 'region' => $data['region'], + 'datacenter' => $data['dataCenter'], + 'image' => $data['imageId'], + 'product_id' => $data['productId'], + 'cpu_cores' => $data['cpuCores'] ?? null, + 'ram_mb' => $data['ramMb'] ?? null, + 'disk_mb' => $data['diskMb'] ?? null, + ]; + } + + public function getInstance(string $instanceId): ?array + { + $response = $this->client()->get("/compute/instances/{$instanceId}"); + + if (!$response->successful()) { + if ($response->status() === 404) { + return null; + } + throw new \RuntimeException('Failed to get Contabo instance: ' . $response->body()); + } + + $data = $response->json('data.0'); + + return [ + 'instance_id' => $data['instanceId'], + 'name' => $data['displayName'], + 'ip_address' => $data['ipConfig']['v4']['ip'] ?? null, + 'ipv6_address' => $data['ipConfig']['v6']['ip'] ?? null, + 'status' => $this->mapStatus($data['status']), + 'power_status' => $data['status'] === 'running' ? 'on' : 'off', + 'region' => $data['region'], + 'datacenter' => $data['dataCenter'], + 'image' => $data['imageId'], + 'product_id' => $data['productId'], + 'cpu_cores' => $data['cpuCores'] ?? null, + 'ram_mb' => $data['ramMb'] ?? null, + 'disk_mb' => $data['diskMb'] ?? null, + 'created_at' => $data['createdDate'] ?? null, + ]; + } + + public function listInstances(array $filters = []): array + { + $query = array_filter([ + 'page' => $filters['page'] ?? 1, + 'size' => $filters['per_page'] ?? 100, + 'name' => $filters['name'] ?? null, + 'region' => $filters['region'] ?? null, + 'status' => $filters['status'] ?? null, + ]); + + $response = $this->client()->get('/compute/instances', $query); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to list Contabo instances: ' . $response->body()); + } + + $instances = []; + foreach ($response->json('data', []) as $data) { + $instances[] = [ + 'instance_id' => $data['instanceId'], + 'name' => $data['displayName'], + 'ip_address' => $data['ipConfig']['v4']['ip'] ?? null, + 'status' => $this->mapStatus($data['status']), + 'region' => $data['region'], + ]; + } + + return [ + 'instances' => $instances, + 'total' => $response->json('_pagination.totalElements', count($instances)), + ]; + } + + public function startInstance(string $instanceId): bool + { + $response = $this->client()->post("/compute/instances/{$instanceId}/actions/start"); + return $response->successful(); + } + + public function stopInstance(string $instanceId): bool + { + $response = $this->client()->post("/compute/instances/{$instanceId}/actions/stop"); + return $response->successful(); + } + + public function rebootInstance(string $instanceId): bool + { + $response = $this->client()->post("/compute/instances/{$instanceId}/actions/restart"); + return $response->successful(); + } + + public function deleteInstance(string $instanceId): bool + { + $response = $this->client()->delete("/compute/instances/{$instanceId}"); + return $response->successful(); + } + + public function reinstallInstance(string $instanceId, string $image): bool + { + $response = $this->client()->put("/compute/instances/{$instanceId}", [ + 'imageId' => $image, + ]); + return $response->successful(); + } + + public function createSnapshot(string $instanceId, string $name): array + { + $response = $this->client()->post("/compute/instances/{$instanceId}/snapshots", [ + 'name' => $name, + 'description' => "Snapshot created by Ladill on " . now()->toDateTimeString(), + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to create snapshot: ' . $response->body()); + } + + $data = $response->json('data.0'); + + return [ + 'snapshot_id' => $data['snapshotId'], + 'name' => $data['name'], + 'status' => $data['status'] ?? 'creating', + ]; + } + + public function listSnapshots(string $instanceId): array + { + $response = $this->client()->get("/compute/instances/{$instanceId}/snapshots"); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to list snapshots: ' . $response->body()); + } + + $snapshots = []; + foreach ($response->json('data', []) as $data) { + $snapshots[] = [ + 'snapshot_id' => $data['snapshotId'], + 'name' => $data['name'], + 'status' => $data['status'] ?? 'available', + 'created_at' => $data['createdDate'] ?? null, + ]; + } + + return $snapshots; + } + + public function deleteSnapshot(string $snapshotId): bool + { + $response = $this->client()->delete("/compute/snapshots/{$snapshotId}"); + return $response->successful(); + } + + public function restoreSnapshot(string $instanceId, string $snapshotId): bool + { + $response = $this->client()->post("/compute/instances/{$instanceId}/snapshots/{$snapshotId}/rollback"); + return $response->successful(); + } + + public function getAvailableImages(): array + { + $images = []; + + $page = 1; + $pageSize = 100; + do { + $response = $this->client()->get('/compute/images', [ + 'page' => $page, + 'size' => $pageSize, + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to get images: ' . $response->body()); + } + + $batch = $response->json('data', []); + foreach ($batch as $data) { + $images[] = [ + 'id' => $data['imageId'], + 'name' => $data['name'], + 'description' => $data['description'] ?? '', + 'os_type' => $data['osType'] ?? 'linux', + 'standard_image' => $data['standardImage'] ?? true, + ]; + } + + $page++; + } while (count($batch) === $pageSize); + + if ($images === []) { + throw new \RuntimeException('Failed to get images: empty response'); + } + + return $images; + } + + public function getAvailableRegions(): array + { + return [ + ['id' => 'EU', 'name' => 'European Union', 'country' => 'DE'], + ['id' => 'US-central', 'name' => 'United States Central', 'country' => 'US'], + ['id' => 'US-east', 'name' => 'United States East', 'country' => 'US'], + ['id' => 'US-west', 'name' => 'United States West', 'country' => 'US'], + ['id' => 'SIN', 'name' => 'Singapore', 'country' => 'SG'], + ['id' => 'UK', 'name' => 'United Kingdom', 'country' => 'GB'], + ['id' => 'AUS', 'name' => 'Australia', 'country' => 'AU'], + ['id' => 'JPN', 'name' => 'Japan', 'country' => 'JP'], + ]; + } + + public function getAvailableApplications(): array + { + return Cache::remember('contabo_applications', 3600, function () { + $applications = []; + + $page = 1; + $pageSize = 100; + do { + $response = $this->client()->get('/compute/applications', [ + 'page' => $page, + 'size' => $pageSize, + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to get applications: ' . $response->body()); + } + + $batch = $response->json('data', []); + foreach ($batch as $data) { + $applications[] = [ + 'id' => $data['applicationId'], + 'name' => $data['name'], + 'description' => $data['description'] ?? '', + 'version' => $data['version'] ?? null, + 'url' => $data['url'] ?? null, + ]; + } + + $page++; + } while (count($batch) === $pageSize); + + return $applications; + }); + } + + public function getAvailableProducts(): array + { + return Cache::remember('contabo_products', 3600, function () { + $liveProducts = $this->fetchProductCatalog(); + + return $liveProducts !== [] ? $liveProducts : $this->fallbackProducts(); + }); + } + + private function fetchProductCatalog(): array + { + $endpoint = trim((string) $this->productCatalogEndpoint); + + if ($endpoint === '') { + return []; + } + + $response = str_starts_with($endpoint, 'http') + ? Http::withToken($this->getAccessToken()) + ->withHeaders(['x-request-id' => Str::uuid()->toString()]) + ->timeout(60) + ->get($endpoint) + : $this->client()->get($endpoint); + + if (! $response->successful()) { + Log::warning('Contabo product catalog lookup failed', [ + 'endpoint' => $endpoint, + 'status' => $response->status(), + 'response' => Str::limit($response->body(), 500), + ]); + + return []; + } + + $rows = $response->json('data', $response->json('products', $response->json())); + + if (! is_array($rows)) { + return []; + } + + $products = []; + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + + $product = $this->normalizeProductCatalogRow($row); + if ($product !== null) { + $products[] = $product; + } + } + + return $products; + } + + private function normalizeProductCatalogRow(array $row): ?array + { + $id = (string) ($row['id'] ?? $row['productId'] ?? $row['product_id'] ?? ''); + + if ($id === '') { + return null; + } + + $monthlyPrice = $row['price_monthly'] + ?? $row['monthly'] + ?? $row['monthly_usd'] + ?? $row['monthlyUsd'] + ?? $row['monthlyPrice'] + ?? $row['price'] + ?? null; + + if (! is_numeric($monthlyPrice)) { + return null; + } + + return [ + 'id' => $id, + 'name' => (string) ($row['name'] ?? $row['product'] ?? $id), + 'cpu' => isset($row['cpu']) ? (int) $row['cpu'] : (isset($row['cpuCores']) ? (int) $row['cpuCores'] : null), + 'ram_gb' => isset($row['ram_gb']) ? (float) $row['ram_gb'] : (isset($row['ramGb']) ? (float) $row['ramGb'] : null), + 'disk_gb' => isset($row['disk_gb']) ? (float) $row['disk_gb'] : (isset($row['diskGb']) ? (float) $row['diskGb'] : null), + 'price_monthly' => (float) $monthlyPrice, + ]; + } + + private function fallbackProducts(): array + { + return [ + ['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99], + ['id' => 'V94', 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100, 'price_monthly' => 7.00], + ['id' => 'V97', 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200, 'price_monthly' => 14.00], + ['id' => 'V100', 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250, 'price_monthly' => 25.00], + ['id' => 'V45', 'name' => 'Legacy VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99], + ['id' => 'V47', 'name' => 'Legacy VPS M SSD', 'cpu' => 6, 'ram_gb' => 16, 'disk_gb' => 400, 'price_monthly' => 12.99], + ['id' => 'V46', 'name' => 'Legacy VPS L SSD', 'cpu' => 8, 'ram_gb' => 30, 'disk_gb' => 800, 'price_monthly' => 22.99], + ['id' => 'V48', 'name' => 'Legacy VPS XL SSD', 'cpu' => 10, 'ram_gb' => 60, 'disk_gb' => 1600, 'price_monthly' => 44.99], + ]; + } + + private function mapStatus(string $contaboStatus): string + { + return match ($contaboStatus) { + 'running' => 'running', + 'stopped' => 'stopped', + 'provisioning', 'installing' => 'provisioning', + 'error' => 'failed', + default => 'unknown', + }; + } + + public function createPasswordSecret(string $displayName, string $password): int + { + $response = $this->client()->post('/secrets', [ + 'name' => Str::limit($displayName, 200, '').'-password', + 'value' => $password, + 'type' => 'password', + ]); + + if (! $response->successful()) { + Log::error('Contabo secret creation failed', ['response' => $response->body()]); + throw new \RuntimeException('Failed to create Contabo password secret: '.$response->body()); + } + + return (int) $response->json('data.0.secretId'); + } + + public function generateCloudInit(array $config): string + { + $packages = $config['packages'] ?? ['curl', 'wget', 'git', 'unzip']; + $sshKeys = $config['ssh_keys'] ?? []; + $writeFiles = $config['write_files'] ?? []; + $runCommands = $config['run_commands'] ?? []; + + $cloudInit = [ + '#cloud-config', + 'package_update: true', + 'package_upgrade: true', + 'packages:', + ]; + + foreach ($packages as $package) { + $cloudInit[] = " - {$package}"; + } + + if (!empty($sshKeys)) { + $cloudInit[] = 'ssh_authorized_keys:'; + foreach ($sshKeys as $key) { + $cloudInit[] = " - {$key}"; + } + } + + if (! empty($writeFiles)) { + $cloudInit[] = 'write_files:'; + foreach ($writeFiles as $file) { + $cloudInit[] = ' - path: ' . ($file['path'] ?? '/tmp/ladill-file'); + if (! empty($file['owner'])) { + $cloudInit[] = ' owner: ' . $file['owner']; + } + if (! empty($file['permissions'])) { + $cloudInit[] = ' permissions: "' . $file['permissions'] . '"'; + } + if (! empty($file['encoding'])) { + $cloudInit[] = ' encoding: ' . $file['encoding']; + } + $cloudInit[] = ' content: |'; + foreach (preg_split("/\r\n|\n|\r/", (string) ($file['content'] ?? '')) ?: [] as $contentLine) { + $cloudInit[] = ' ' . $contentLine; + } + } + } + + if (!empty($runCommands)) { + $cloudInit[] = 'runcmd:'; + foreach ($runCommands as $cmd) { + $cloudInit[] = " - {$cmd}"; + } + } + + return implode("\n", $cloudInit); + } +} diff --git a/app/Services/Hosting/Providers/SharedNodeProvider.php b/app/Services/Hosting/Providers/SharedNodeProvider.php new file mode 100644 index 0000000..4783b15 --- /dev/null +++ b/app/Services/Hosting/Providers/SharedNodeProvider.php @@ -0,0 +1,2814 @@ +provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost'; + } + + private function shouldUseLocalExecution(HostingNode $node): bool + { + return $this->isLocal($node) && blank($node->ssh_private_key); + } + + public function connect(HostingNode $node): ?SSH2 + { + $this->connectedNode = $node; + + if ($this->shouldUseLocalExecution($node)) { + $this->isLocalNode = true; + return null; + } + + $this->isLocalNode = false; + + if ($this->ssh && $this->ssh->isConnected()) { + return $this->ssh; + } + + // For local nodes with SSH keys, connect to 127.0.0.1 + $host = $this->isLocal($node) ? '127.0.0.1' : $node->ip_address; + $this->ssh = new SSH2($host, (int) ($node->ssh_port ?: 22)); + $this->ssh->setTimeout(300); + + // Load the private key properly for phpseclib3 + $credential = $node->ssh_private_key; + if ($this->looksLikeSshKey($credential)) { + try { + $credential = PublicKeyLoader::load($credential); + } catch (\Throwable $e) { + throw new \RuntimeException("Invalid SSH key for node {$node->hostname}: " . $e->getMessage()); + } + } + + if (!$this->ssh->login('root', $credential)) { + throw new \RuntimeException("Failed to connect to hosting node: {$node->hostname}"); + } + + return $this->ssh; + } + + private function disconnectRemoteSession(): void + { + if ($this->ssh instanceof SSH2) { + try { + $this->ssh->disconnect(); + } catch (\Throwable) { + // Ignore teardown failures and force a clean reconnect path. + } + } + + $this->ssh = null; + } + + private function looksLikeSshKey(string $value): bool + { + return str_contains($value, '-----BEGIN') || str_contains($value, 'PRIVATE KEY'); + } + + private function exec(?SSH2 &$ssh, string $command): string + { + if ($this->isLocalNode) { + return $this->runLocalProcess($command)['output']; + } + + // Ensure connection is still alive. Some long administrative flows, like SSL issuance, + // can leave phpseclib holding a stale channel between individual remote commands. + if (!$ssh instanceof SSH2 || !$ssh->isConnected()) { + if ($this->ssh instanceof SSH2 && $this->ssh->isConnected()) { + $ssh = $this->ssh; + } else { + Log::warning('SharedNodeProvider detected a lost SSH connection; reconnecting.', [ + 'node_id' => $this->connectedNode?->id, + 'node_name' => $this->connectedNode?->name, + 'command' => $command, + ]); + + if (! $this->connectedNode) { + throw new \RuntimeException('SSH connection lost'); + } + + $this->disconnectRemoteSession(); + $ssh = $this->connect($this->connectedNode); + } + } + + try { + $output = $ssh->exec($command); + } catch (\RuntimeException $e) { + if (! str_contains($e->getMessage(), 'Please close the channel')) { + throw $e; + } + + Log::warning('SharedNodeProvider detected a stale SSH exec channel; reconnecting and retrying command.', [ + 'node_id' => $this->connectedNode?->id, + 'node_name' => $this->connectedNode?->name, + 'command' => $command, + ]); + + $this->disconnectRemoteSession(); + + if (! $this->connectedNode) { + throw $e; + } + + $ssh = $this->connect($this->connectedNode); + $output = $ssh?->exec($command); + } + + // Allow the SSH library a moment to fully settle channel bookkeeping + // before the next exec on the shared connection. + usleep(10000); + + return (string) $output; + } + + private function getExitStatus(?SSH2 $ssh): int + { + if ($this->isLocalNode) { + return $this->lastLocalExitCode; + } + + return $ssh->getExitStatus() ?? 0; + } + + private function runLocalProcess(string $command, int $timeout = 600): array + { + // Use forever() for long-running commands, with explicit timeout as fallback + $result = Process::timeout($timeout)->idleTimeout($timeout)->run($command); + $this->lastLocalExitCode = $result->exitCode(); + + return [ + 'output' => $result->output() . $result->errorOutput(), + 'exit_code' => $this->lastLocalExitCode, + ]; + } + + private function runLocalAccountCommand(string $username, string $command, int $timeout = 600): array + { + $helperResult = $this->runLocalUserHelper($username, $command, $timeout); + + if ($helperResult['exit_code'] === 0 || ! $this->isLocalHelperUnavailable($helperResult['output'])) { + if ($this->isLocalUserSwitchFailure('sudo', $helperResult['output'])) { + return [ + 'output' => $this->localUserSwitchUnavailableMessage(), + 'exit_code' => $helperResult['exit_code'], + ]; + } + + return $helperResult; + } + + $sudoCommand = "sudo -n -u {$username} -- bash -lc " . escapeshellarg($command); + $result = $this->runLocalProcess($sudoCommand, $timeout); + + if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('sudo', $result['output'])) { + return $result; + } + + $runUserCommand = "runuser -u {$username} -- bash -lc " . escapeshellarg($command); + $result = $this->runLocalProcess($runUserCommand, $timeout); + + if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('runuser', $result['output'])) { + return $result; + } + + if ($this->isCurrentProcessUser($username)) { + return $this->runLocalProcess($command, $timeout); + } + + return [ + 'output' => $this->localUserSwitchUnavailableMessage(), + 'exit_code' => 1, + ]; + } + + private function localUserSwitchUnavailableMessage(): string + { + return 'Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.'; + } + + private function localAdministrativeAccessUnavailableMessage(): string + { + return 'Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.'; + } + + private function isCurrentProcessUser(string $username): bool + { + if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) { + return false; + } + + $currentUser = posix_getpwuid(posix_geteuid()); + + return isset($currentUser['name']) && $currentUser['name'] === $username; + } + + private function runLocalUserHelper(string $username, string $command, int $timeout = 600): array + { + $payload = base64_encode($command); + $parts = ['sudo', '-n', self::LOCAL_USER_HELPER, $username, $payload]; + + return $this->runLocalProcess(collect($parts) + ->map(fn (string $part): string => escapeshellarg($part)) + ->implode(' '), $timeout); + } + + private function runLocalAdminHelper(string $operation, array $arguments = []): array + { + $parts = ['sudo', '-n', self::LOCAL_ADMIN_HELPER, $operation]; + + foreach ($arguments as $argument) { + $parts[] = $argument; + } + + return $this->runLocalProcess(collect($parts) + ->map(fn (string $part): string => escapeshellarg($part)) + ->implode(' ')); + } + + private function isLocalHelperUnavailable(string $output): bool + { + $normalized = Str::lower($output); + + foreach ([ + 'no such file or directory', + 'command not found', + 'unable to execute', + ] as $needle) { + if (str_contains($normalized, $needle)) { + return true; + } + } + + return false; + } + + private function isCurrentProcessRoot(): bool + { + return function_exists('posix_geteuid') && posix_geteuid() === 0; + } + + private function isLocalUserSwitchFailure(string $method, string $output): bool + { + $normalized = Str::lower($output); + + $needles = match ($method) { + 'sudo' => [ + 'sudo: a password is required', + 'sudo: sorry, you must have a tty', + 'sudo: command not found', + 'not in the sudoers', + 'not allowed to execute', + 'unknown user', + ], + 'runuser' => [ + 'runuser: may not be used by non-root users', + 'runuser: failed to execute', + 'runuser: user', + 'permission denied', + ], + default => [], + }; + + foreach ($needles as $needle) { + if (str_contains($normalized, $needle)) { + return true; + } + } + + return false; + } + + private function shouldRetryLocalAdminCommandWithSudo(string $output): bool + { + $normalized = Str::lower($output); + + foreach ([ + 'permission denied', + 'operation not permitted', + 'not owner', + 'must be run as root', + 'run this program as root', + 'superuser', + 'authentication is required', + ] as $needle) { + if (str_contains($normalized, $needle)) { + return true; + } + } + + return false; + } + + private function executeAdministrativeCommand(?SSH2 $ssh, string $command): array + { + if ($this->isLocalNode) { + $result = $this->runLocalProcess($command); + + if ($result['exit_code'] !== 0 && $this->shouldRetryLocalAdminCommandWithSudo($result['output'])) { + $sudoResult = $this->runLocalProcess("sudo -n bash -lc " . escapeshellarg($command)); + + if ($sudoResult['exit_code'] === 0) { + return $sudoResult; + } + + if ($this->isLocalUserSwitchFailure('sudo', $sudoResult['output'])) { + $message = trim((string) $result['output']); + $message = $message !== '' ? $message . PHP_EOL : ''; + $message .= $this->localAdministrativeAccessUnavailableMessage(); + + return [ + 'output' => $message, + 'exit_code' => $sudoResult['exit_code'], + ]; + } + + return $sudoResult; + } + + return $result; + } + + $output = $this->exec($ssh, $command); + + return [ + 'output' => $output, + 'exit_code' => $this->getExitStatus($ssh), + ]; + } + + private function ensureLocalAdministrativeAccess(): void + { + if (! $this->isLocalNode || $this->isCurrentProcessRoot()) { + return; + } + + $result = $this->runLocalAdminHelper('self-check'); + + if ($result['exit_code'] === 0) { + return; + } + + if ($this->isLocalHelperUnavailable($result['output'])) { + $result = $this->runLocalProcess('sudo -n true'); + } + + if ($result['exit_code'] === 0) { + return; + } + + throw new \RuntimeException($this->localAdministrativeAccessUnavailableMessage()); + } + + private function executeLocalAdminOperation(string $operation, array $arguments, callable $fallback): array + { + $result = $this->runLocalAdminHelper($operation, $arguments); + + if ($result['exit_code'] === 0 || ! $this->isLocalHelperUnavailable($result['output'])) { + if ($this->isLocalUserSwitchFailure('sudo', $result['output'])) { + return [ + 'output' => $this->localAdministrativeAccessUnavailableMessage(), + 'exit_code' => $result['exit_code'], + ]; + } + + return $result; + } + + return $fallback(); + } + + /** + * Create a new hosting account on a node (used by new order flow) + */ + public function createAccountOnNode(HostingNode $node, array $config): array + { + $ssh = $this->connect($node); + $username = $config['username']; + $password = $config['password']; + $domain = $config['domain']; + $homeDir = "/home/{$username}"; + $docRoot = "{$homeDir}/public_html"; + + // Create system user + $commands = [ + "useradd -m -d {$homeDir} -s /bin/bash {$username}", + "echo '{$username}:{$password}' | chpasswd", + "mkdir -p {$docRoot} {$homeDir}/logs {$homeDir}/tmp {$homeDir}/backups", + "chown -R {$username}:{$username} {$homeDir}", + ]; + + foreach ($commands as $cmd) { + $result = $this->exec($ssh, $cmd); + if ($this->getExitStatus($ssh) !== 0) { + Log::error("Failed to execute: {$cmd}", ['output' => $result]); + } + } + + // Create PHP-FPM pool + $this->createPhpFpmPoolForUser($ssh, $username, $config['php_version'] ?? '8.4'); + + $this->hardenAccountFilesystemPaths($ssh, $username, [$docRoot]); + + // Create nginx config for domain + $this->createNginxConfigForDomain($ssh, $domain, $docRoot, $username); + + // Set disk quota if specified + if (!empty($config['disk_quota_mb'])) { + $quotaKb = $config['disk_quota_mb'] * 1024; + $this->exec($ssh, "setquota -u {$username} {$quotaKb} {$quotaKb} 0 0 /home 2>/dev/null || true"); + } + + return [ + 'username' => $username, + 'password' => $password, + 'home_directory' => $homeDir, + 'document_root' => $docRoot, + 'domain' => $domain, + ]; + } + + /** + * Install WordPress on an account + */ + public function installWordPress(HostingNode $node, string $username, string $domain, array $options = []): array + { + $ssh = $this->connect($node); + $docRoot = $options['document_root'] ?? "/home/{$username}/public_html"; + $dbName = substr(preg_replace('/[^a-z0-9]/', '', strtolower($username)), 0, 12) . '_wp'; + $dbUser = $dbName; + $dbPass = Str::password(16, true, true, false, false); + $adminEmail = $options['admin_email'] ?? 'admin@' . $domain; + $adminUser = $options['admin_user'] ?? 'admin'; + $adminPassword = $options['admin_password'] ?? Str::password(12, true, true, false, false); + $siteTitle = $options['site_title'] ?? $domain; + + // Create database + $this->exec($ssh, "mysql -e \"CREATE DATABASE IF NOT EXISTS \\`{$dbName}\\`\""); + $this->exec($ssh, "mysql -e \"CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$dbPass}'\""); + $this->exec($ssh, "mysql -e \"GRANT ALL PRIVILEGES ON \\`{$dbName}\\`.* TO '{$dbUser}'@'localhost'\""); + $this->exec($ssh, "mysql -e \"FLUSH PRIVILEGES\""); + + // Download WordPress + $commands = [ + "cd {$docRoot} && rm -rf * 2>/dev/null || true", + "cd {$docRoot} && curl -sO https://wordpress.org/latest.tar.gz", + "cd {$docRoot} && tar -xzf latest.tar.gz --strip-components=1", + "cd {$docRoot} && rm latest.tar.gz", + "cd {$docRoot} && cp wp-config-sample.php wp-config.php", + "cd {$docRoot} && sed -i \"s/database_name_here/{$dbName}/g\" wp-config.php", + "cd {$docRoot} && sed -i \"s/username_here/{$dbUser}/g\" wp-config.php", + "cd {$docRoot} && sed -i \"s/password_here/{$dbPass}/g\" wp-config.php", + "chown -R {$username}:{$username} {$docRoot}", + ]; + + foreach ($commands as $cmd) { + $this->exec($ssh, $cmd); + } + + // Generate unique security keys using WP-CLI (safer than manual sed/echo) + $this->exec($ssh, "cd {$docRoot} && runuser -u {$username} -- wp config shuffle-salts 2>&1"); + + // Run wp core install to create admin user + $escapedTitle = addslashes($siteTitle); + $escapedAdminUser = escapeshellarg($adminUser); + $escapedAdminPass = escapeshellarg($adminPassword); + $escapedAdminEmail = escapeshellarg($adminEmail); + $wpInstallCmd = "cd {$docRoot} && runuser -u {$username} -- wp core install --url='https://{$domain}' --title='{$escapedTitle}' --admin_user={$escapedAdminUser} --admin_password={$escapedAdminPass} --admin_email={$escapedAdminEmail} --skip-email 2>&1"; + $this->exec($ssh, $wpInstallCmd); + + return [ + 'db_name' => $dbName, + 'db_user' => $dbUser, + 'db_password' => $dbPass, + 'admin_user' => $adminUser, + 'admin_password' => $adminPassword, + 'admin_email' => $adminEmail, + ]; + } + + private function createPhpFpmPoolForUser(?SSH2 $ssh, string $username, string $phpVersion = '8.4'): void + { + foreach ($this->discoverPhpFpmPoolVersions($ssh, $username) as $existingVersion) { + if ($existingVersion === $phpVersion) { + continue; + } + + $poolPath = "/etc/php/{$existingVersion}/fpm/pool.d/{$username}.conf"; + $this->exec($ssh, 'rm -f '.escapeshellarg($poolPath)); + $this->exec($ssh, "systemctl reload php{$existingVersion}-fpm 2>/dev/null || true"); + } + + $poolConfig = $this->buildPhpFpmPoolConfig($username, [ + 'memory_limit_mb' => 256, + 'process_limit' => 5, + 'uploads_restricted' => false, + ]); + + $poolPath = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; + $escapedConfig = str_replace("'", "'\\'", $poolConfig); + $this->exec($ssh, "echo '{$escapedConfig}' > ".escapeshellarg($poolPath)); + $this->exec($ssh, "php-fpm{$phpVersion} -t 2>&1 || php{$phpVersion}-fpm -t 2>&1"); + $this->exec($ssh, "systemctl reload php{$phpVersion}-fpm"); + } + + private function createNginxConfigForDomain(?SSH2 $ssh, string $domain, string $docRoot, string $username): void + { + $site = new HostedSite([ + 'domain' => $domain, + 'document_root' => $docRoot, + 'php_version' => '8.4', + 'ssl_enabled' => false, + 'ssl_status' => null, + ]); + + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ + 'ssl_enabled' => false, + ]); + } + + public function createAccount(HostingAccount $account): array + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned to account'); + } + + $ssh = $this->connect($node); + $username = $account->username; + $homeDir = "/home/{$username}"; + $password = Str::password(16); + + // Create system user + $commands = [ + "useradd -m -d {$homeDir} -s /bin/bash {$username}", + "echo '{$username}:{$password}' | chpasswd", + "mkdir -p {$homeDir}/public_html {$homeDir}/logs {$homeDir}/tmp {$homeDir}/backups", + "chown -R {$username}:{$username} {$homeDir}", + ]; + + foreach ($commands as $cmd) { + $result = $this->exec($ssh, $cmd); + if ($this->getExitStatus($ssh) !== 0) { + Log::error("Failed to execute: {$cmd}", ['output' => $result]); + } + } + + // Create PHP-FPM pool + $this->createPhpFpmPool($ssh, $account); + $this->hardenAccountFilesystem($account); + + return [ + 'username' => $username, + 'password' => $password, + 'home_directory' => $homeDir, + 'document_root' => "{$homeDir}/public_html", + ]; + } + + public function suspendAccount(HostingAccount $account, string $reason): bool + { + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + + // Disable user login and stop PHP-FPM pool + $this->exec($ssh, "usermod -L {$username}"); + $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); + + // Disable nginx configs for all sites + foreach ($account->sites as $site) { + $this->exec($ssh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); + } + $this->exec($ssh, "nginx -t && systemctl reload nginx"); + + return true; + } + + public function serveExpiredPage(HostingAccount $account): bool + { + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + + // Stop PHP-FPM but keep user login enabled so they can access files via panel + $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); + + // Create a simple expired page in the user's home directory + $expiredHtml = <<<'HTML' + + + + + +Hosting Account Expired + + + +
+
+

Hosting Account Expired

+

This hosting account has expired. The account owner can renew it from their dashboard to bring the site back online.

+
+ + +HTML; + + $expiredPageDir = "/home/{$username}/expired_page"; + $this->exec($ssh, "mkdir -p {$expiredPageDir}"); + $encodedHtml = base64_encode($expiredHtml); + $this->exec($ssh, "echo '{$encodedHtml}' | base64 -d > {$expiredPageDir}/index.html"); + $this->exec($ssh, "chown -R {$username}:{$username} {$expiredPageDir}"); + + // Replace each site's nginx config with one that serves the expired page + foreach ($account->sites as $site) { + $domain = $site->domain; + $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; + + $certCheck = $this->exec($ssh, "test -f {$certPath} && test -f {$keyPath} && echo 'exists'"); + $sslExists = trim($certCheck) === 'exists'; + + if ($sslExists) { + $expiredConfig = <<exec($ssh, "echo '{$escapedConfig}' > /etc/nginx/sites-available/{$domain}.conf"); + $this->exec($ssh, "ln -sf /etc/nginx/sites-available/{$domain}.conf /etc/nginx/sites-enabled/"); + } + + $this->exec($ssh, "nginx -t && systemctl reload nginx"); + + return true; + } + + public function unsuspendAccount(HostingAccount $account): bool + { + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + + // Re-enable user and PHP-FPM + $this->exec($ssh, "usermod -U {$username}"); + $this->exec($ssh, "systemctl start php-fpm-{$username} 2>/dev/null || true"); + + // Re-enable nginx configs + foreach ($account->sites as $site) { + $this->exec($ssh, "ln -sf /etc/nginx/sites-available/{$site->domain}.conf /etc/nginx/sites-enabled/"); + } + $this->exec($ssh, "nginx -t && systemctl reload nginx"); + + return true; + } + + public function terminateAccount(HostingAccount $account): bool + { + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + + // Remove nginx configs + foreach ($account->sites as $site) { + $this->exec($ssh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); + $this->exec($ssh, "rm -f /etc/nginx/sites-available/{$site->domain}.conf"); + } + + // Stop and remove PHP-FPM pool + $this->exec($ssh, "systemctl stop php-fpm-{$username} 2>/dev/null || true"); + $this->exec($ssh, "rm -f /etc/php/*/fpm/pool.d/{$username}.conf"); + + // Remove databases + foreach ($account->databases as $db) { + $this->exec($ssh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$db->name}\\`\""); + $this->exec($ssh, "mysql -e \"DROP USER IF EXISTS '{$db->username}'@'localhost'\""); + } + + // Remove user and home directory + $this->exec($ssh, "userdel -r {$username} 2>/dev/null || true"); + + $this->exec($ssh, "nginx -t && systemctl reload nginx"); + $this->exec($ssh, "systemctl reload php*-fpm"); + + return true; + } + + public function changePassword(HostingAccount $account, string $newPassword): bool + { + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $this->exec($ssh, "echo '{$account->username}:{$newPassword}' | chpasswd"); + + return $this->getExitStatus($ssh) === 0; + } + + public function installTeamMemberSshKey(HostingAccount $account, string $marker, string $publicKey): void + { + $node = $account->node; + if (! $node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $username = $account->username; + $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); + $tmpPath = "{$authorizedKeysPath}.tmp"; + $keyLine = trim($publicKey); + + if (! str_contains($keyLine, $marker)) { + $keyLine .= ' '.$marker; + } + + $command = implode("\n", [ + 'set -e', + 'mkdir -p '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), + 'touch '.escapeshellarg($authorizedKeysPath), + 'grep -v -F '.escapeshellarg($marker).' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg($tmpPath).' || true', + 'mv '.escapeshellarg($tmpPath).' '.escapeshellarg($authorizedKeysPath), + 'printf "%s\n" '.escapeshellarg($keyLine).' >> '.escapeshellarg($authorizedKeysPath), + 'chown root:root '.escapeshellarg($authorizedKeysPath), + 'chmod 600 '.escapeshellarg($authorizedKeysPath), + ]); + + $this->ensureAdministrativeCommandSucceeded( + $ssh, + 'bash -lc '.escapeshellarg($command), + 'Failed to install developer SSH key.' + ); + + $this->hardenAccountFilesystem($account); + } + + public function removeTeamMemberSshKey(HostingAccount $account, string $marker): void + { + $node = $account->node; + if (! $node) { + return; + } + + $ssh = $this->connect($node); + $username = $account->username; + $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); + $tmpPath = "{$authorizedKeysPath}.tmp"; + + $command = implode("\n", [ + 'set -e', + 'if [ ! -f '.escapeshellarg($authorizedKeysPath).' ]; then exit 0; fi', + 'grep -v -F '.escapeshellarg($marker).' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg($tmpPath).' || true', + 'mv '.escapeshellarg($tmpPath).' '.escapeshellarg($authorizedKeysPath), + 'chown root:root '.escapeshellarg($authorizedKeysPath), + 'chmod 600 '.escapeshellarg($authorizedKeysPath), + ]); + + $this->ensureAdministrativeCommandSucceeded( + $ssh, + 'bash -lc '.escapeshellarg($command), + 'Failed to revoke developer SSH key.' + ); + } + + public function addSite(HostedSite $site): array + { + $account = $site->account; + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $username = $account->username; + $domain = $site->domain; + $docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}"; + + // Create document root + $quotedDocRoot = escapeshellarg($docRoot); + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"), + "Failed to create document root for {$domain}" + ); + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'chown', ["{$username}:{$username}", $docRoot], "chown {$username}:{$username} {$quotedDocRoot}"), + "Failed to assign document root ownership for {$domain}" + ); + $this->hardenAccountFilesystem($account, [$docRoot]); + + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + + return [ + 'document_root' => $docRoot, + 'domain' => $domain, + ]; + } + + public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void + { + $node = $account->node; + if (! $node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $documentRoots = collect($account->sites ?? []) + ->pluck('document_root') + ->merge($additionalDocumentRoots) + ->filter() + ->values() + ->all(); + + $this->ensureSharedSftpIsolationPolicy($ssh, $account->username); + $this->hardenAccountFilesystemPaths($ssh, $account->username, $documentRoots); + } + + public function removeSite(HostedSite $site): bool + { + $account = $site->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $domain = $site->domain; + $configPath = "/etc/nginx/sites-available/{$domain}.conf"; + $enabledPath = "/etc/nginx/sites-enabled/{$domain}.conf"; + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ['rm -f ' . escapeshellarg($enabledPath) . ' ' . escapeshellarg($configPath)], + 'rm -f ' . escapeshellarg($enabledPath) . ' ' . escapeshellarg($configPath) + ), + "Failed to remove nginx configuration for {$domain}" + ); + + $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); + if ($testResult['exit_code'] !== 0 || strpos((string) ($testResult['output'] ?? ''), 'successful') === false) { + Log::error("Nginx config test failed after site removal for {$domain}", ['output' => $testResult]); + throw new \RuntimeException("Invalid nginx configuration after removing {$domain}"); + } + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'), + "Failed to reload nginx after removing {$domain}" + ); + + $this->runMagentoOpenSearchStateCheck($ssh); + + return true; + } + + public function requestLetsEncryptCertificate(HostedSite $site): bool + { + $account = $site->account; + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $this->ensureLocalAdministrativeAccess(); + $domain = $site->domain; + $email = $account->user?->email ?: 'admin@' . $domain; + $docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}"; + $hadCertificate = $this->siteHasCertificate($ssh, $domain); + + Log::info("SSL: Starting certificate request", [ + 'domain' => $domain, + 'docRoot' => $docRoot, + 'had_certificate' => $hadCertificate, + ]); + + if (! $hadCertificate) { + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ + 'ssl_enabled' => false, + ]); + + Log::info("SSL: Applied HTTP-only config for ACME challenge", ['domain' => $domain]); + } else { + Log::info("SSL: Existing certificate detected, keeping SSL nginx config during certificate request", [ + 'domain' => $domain, + ]); + } + + // Build domain list - include www if we manage DNS or if it resolves + $domainArgs = ' -d ' . escapeshellarg($domain); + $wwwDomain = "www.{$domain}"; + $domainModel = $site->domain_id ? $site->domain()->first() : null; + $dnsIsManaged = $domainModel && $domainModel->dns_mode === 'managed'; + + if ($dnsIsManaged) { + // We manage DNS, so www record exists - always include it + $domainArgs .= ' -d ' . escapeshellarg($wwwDomain); + Log::info("SSL: Including www subdomain (managed DNS)", ['domain' => $domain, 'www' => $wwwDomain]); + } elseif ($this->domainResolvesToServer($wwwDomain)) { + // Manual DNS - only include www if it resolves + $domainArgs .= ' -d ' . escapeshellarg($wwwDomain); + Log::info("SSL: Including www subdomain (resolves)", ['domain' => $domain, 'www' => $wwwDomain]); + } else { + Log::info("SSL: Skipping www subdomain (manual DNS, does not resolve)", ['domain' => $domain, 'www' => $wwwDomain]); + } + + $result = $this->runLocalAdminOperationOrRemote( + $ssh, + 'certbot-webroot', + [$email, $domain, $docRoot], + "certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m " + . escapeshellarg($email) + . ' -w ' + . escapeshellarg($docRoot) + . $domainArgs + . ' 2>&1' + ); + + if ($result['exit_code'] !== 0) { + Log::error("SSL: Certbot failed", ['domain' => $domain, 'output' => $result['output']]); + throw new \RuntimeException(trim((string) $result['output']) ?: "Failed to issue SSL certificate for {$domain}"); + } + + Log::info("SSL: Certbot succeeded, applying SSL nginx config", ['domain' => $domain]); + + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ + 'ssl_enabled' => true, + ]); + + Log::info("SSL: Certificate provisioning complete", ['domain' => $domain]); + + return true; + } + + /** + * @return array{success: bool, output: string, exit_code: int} + */ + public function renewCertificatesOnNode(HostingNode $node, bool $force = false): array + { + $ssh = $this->connect($node); + $this->ensureLocalAdministrativeAccess(); + + $remoteCommand = 'certbot renew --quiet --no-random-sleep-on-renew'; + if ($force) { + $remoteCommand .= ' --force-renewal'; + } + $remoteCommand .= ' 2>&1'; + + $result = $this->runLocalAdminOperationOrRemote( + $ssh, + 'certbot-renew', + [], + $remoteCommand + ); + + $exitCode = (int) ($result['exit_code'] ?? 1); + $output = trim((string) ($result['output'] ?? '')); + + if ($exitCode !== 0) { + Log::warning('SSL: certbot renew completed with errors on node', [ + 'node_id' => $node->id, + 'hostname' => $node->hostname, + 'output' => $output, + 'exit_code' => $exitCode, + ]); + } else { + Log::info('SSL: certbot renew completed on node', [ + 'node_id' => $node->id, + 'hostname' => $node->hostname, + ]); + } + + $this->reloadNginxOnNode($ssh); + + return [ + 'success' => $exitCode === 0, + 'output' => $output, + 'exit_code' => $exitCode, + ]; + } + + public function readCertificateExpiryOnNode(HostingNode $node, string $domain): ?CarbonInterface + { + $ssh = $this->connect($node); + $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; + $command = sprintf( + 'openssl x509 -in %s -noout -enddate 2>/dev/null', + escapeshellarg($certPath) + ); + + $result = $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + [$command], + $command + ); + + if (($result['exit_code'] ?? 1) !== 0) { + return null; + } + + return (new SslCertificateExpiryParser())->parseOpenSslEndDate((string) ($result['output'] ?? '')); + } + + private function reloadNginxOnNode(?SSH2 $ssh): void + { + $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); + + if (($testResult['exit_code'] ?? 1) !== 0) { + Log::warning('SSL: nginx config test failed after renew', [ + 'output' => $testResult['output'] ?? null, + ]); + + return; + } + + $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'); + } + + private function domainResolvesToServer(string $hostname): bool + { + if (! function_exists('dns_get_record')) { + return false; + } + + $records = @dns_get_record($hostname, DNS_A); + if (! is_array($records) || $records === []) { + $cname = @dns_get_record($hostname, DNS_CNAME); + return is_array($cname) && $cname !== []; + } + + return true; + } + + public function createDatabase(HostedDatabase $database, string $password): array + { + $account = $database->account; + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $dbName = $database->name; + $dbUser = $database->username; + + $commands = [ + "CREATE DATABASE IF NOT EXISTS `{$dbName}`", + "CREATE USER IF NOT EXISTS '{$dbUser}'@'localhost' IDENTIFIED BY '{$password}'", + "GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'localhost'", + "FLUSH PRIVILEGES", + ]; + + foreach ($commands as $sql) { + $cmd = "mysql -e " . escapeshellarg($sql); + + if ($this->isLocalNode) { + // Local fallback: needs admin privileges for MySQL + $result = $this->executeAdministrativeCommand($ssh, $cmd); + } else { + // SSH as root: mysql auth_socket works automatically + $output = $this->exec($ssh, $cmd); + $result = [ + 'output' => $output, + 'exit_code' => $this->getExitStatus($ssh), + ]; + } + + if ($result['exit_code'] !== 0) { + throw new \RuntimeException("Database command failed: {$sql}. Output: " . $result['output']); + } + } + + return [ + 'name' => $dbName, + 'username' => $dbUser, + 'host' => 'localhost', + ]; + } + + public function deleteDatabase(HostedDatabase $database): bool + { + $account = $database->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $this->exec($ssh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$database->name}\\`\""); + $this->exec($ssh, "mysql -e \"DROP USER IF EXISTS '{$database->username}'@'localhost'\""); + + return true; + } + + public function changeDatabasePassword(HostedDatabase $database, string $newPassword): bool + { + $account = $database->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $this->exec($ssh, "mysql -e \"ALTER USER '{$database->username}'@'localhost' IDENTIFIED BY '{$newPassword}'\""); + + return $this->getExitStatus($ssh) === 0; + } + + public function changeAccountPhpVersion(HostingAccount $account, string $version): bool + { + $node = $account->node; + if (! $node) { + return false; + } + + $account->loadMissing('sites'); + + $ssh = $this->connect($node); + $currentVersion = $account->php_version ?? '8.4'; + + if ($currentVersion !== $version) { + $this->migratePhpFpmPoolToVersion($ssh, $account, $version, $currentVersion); + } else { + $this->ensureSinglePhpFpmPool($ssh, $account, $version); + } + + foreach ($account->sites as $site) { + try { + $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $account->username, [ + 'php_version' => $version, + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + } catch (\Throwable $e) { + Log::error("Nginx config test failed after PHP version change for {$site->domain}", [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + return true; + } + + public function findPhpFpmPoolVersions(HostingAccount $account): array + { + $node = $account->node; + if (! $node) { + return []; + } + + $ssh = $this->connect($node); + + return $this->discoverPhpFpmPoolVersions($ssh, $account->username); + } + + public function setPhpVersion(HostedSite $site, string $version): bool + { + $account = $site->account; + $node = $account->node; + if (! $node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + $oldVersion = $site->php_version ?? $account->php_version ?? '8.4'; + + if ($oldVersion !== $version) { + $this->migratePhpFpmPoolToVersion($ssh, $account, $version, $oldVersion); + } + + try { + $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [ + 'php_version' => $version, + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + + return true; + } catch (\Throwable $e) { + Log::error("Nginx config test failed after PHP version change for {$site->domain}", [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + private function migratePhpFpmPoolToVersion(?SSH2 $ssh, HostingAccount $account, string $newVersion, string $oldVersion): void + { + $username = $account->username; + $this->logDuplicatePhpFpmPools($ssh, $username, $account); + + $removedVersions = $this->removePhpFpmPoolsForAccount($ssh, $username); + + foreach (array_unique(array_merge($removedVersions, [$oldVersion])) as $version) { + if ($version === $newVersion) { + continue; + } + + $this->reloadPhpFpmService($ssh, $version, required: false); + } + + $this->writePhpFpmPoolConfig($ssh, $account, $newVersion); + + if (! $this->reloadPhpFpmService($ssh, $newVersion)) { + throw new \RuntimeException("PHP-FPM {$newVersion} failed configuration validation for {$username}"); + } + } + + private function ensureSinglePhpFpmPool(?SSH2 $ssh, HostingAccount $account, string $targetVersion): void + { + $username = $account->username; + $existingVersions = $this->discoverPhpFpmPoolVersions($ssh, $username); + + if (count($existingVersions) > 1 || (count($existingVersions) === 1 && $existingVersions[0] !== $targetVersion)) { + $this->migratePhpFpmPoolToVersion($ssh, $account, $targetVersion, $existingVersions[0] ?? $targetVersion); + + return; + } + + if ($existingVersions === []) { + $this->writePhpFpmPoolConfig($ssh, $account, $targetVersion); + $this->reloadPhpFpmService($ssh, $targetVersion); + } + } + + /** + * @return list + */ + private function discoverPhpFpmPoolVersions(?SSH2 $ssh, string $username): array + { + $result = $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["find /etc/php -path '*/fpm/pool.d/{$username}.conf' -type f 2>/dev/null | sort"], + "find /etc/php -path '*/fpm/pool.d/{$username}.conf' -type f 2>/dev/null | sort" + ); + + $versions = []; + foreach (preg_split('/\R/', trim((string) ($result['output'] ?? ''))) ?: [] as $path) { + if ($path === '') { + continue; + } + + if (preg_match('#/etc/php/(\d+\.\d+)/fpm/pool\.d/#', $path, $matches) === 1) { + $versions[] = $matches[1]; + } + } + + return array_values(array_unique($versions)); + } + + /** + * @return list PHP versions that had a pool file removed. + */ + private function removePhpFpmPoolsForAccount(?SSH2 $ssh, string $username): array + { + $removedVersions = []; + + foreach ($this->discoverPhpFpmPoolVersions($ssh, $username) as $version) { + $poolPath = "/etc/php/{$version}/fpm/pool.d/{$username}.conf"; + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["rm -f {$poolPath}"], + 'rm -f '.escapeshellarg($poolPath) + ); + $removedVersions[] = $version; + } + + return $removedVersions; + } + + private function logDuplicatePhpFpmPools(?SSH2 $ssh, string $username, HostingAccount $account): void + { + $versions = $this->discoverPhpFpmPoolVersions($ssh, $username); + + if (count($versions) <= 1) { + return; + } + + Log::warning('Duplicate PHP-FPM pool configs detected for hosting account', [ + 'account_id' => $account->id, + 'username' => $username, + 'php_versions' => $versions, + 'socket' => $this->phpFpmSocketPath($username), + ]); + } + + private function phpFpmSocketPath(string $username): string + { + return "/run/php/php-fpm-{$username}.sock"; + } + + private function testPhpFpmConfig(?SSH2 $ssh, string $phpVersion): bool + { + $command = "php-fpm{$phpVersion} -t 2>&1 || php{$phpVersion}-fpm -t 2>&1"; + $result = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', [$command], $command); + $output = strtolower((string) ($result['output'] ?? '')); + + return $result['exit_code'] === 0 + && (str_contains($output, 'test is successful') || str_contains($output, 'configuration file')); + } + + private function reloadPhpFpmService(?SSH2 $ssh, string $phpVersion, bool $required = true): bool + { + if (! $this->testPhpFpmConfig($ssh, $phpVersion)) { + Log::error('PHP-FPM configuration test failed', [ + 'php_version' => $phpVersion, + ]); + + if ($required) { + return false; + } + + return true; + } + + $command = "systemctl reload php{$phpVersion}-fpm 2>/dev/null || systemctl restart php{$phpVersion}-fpm"; + $result = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', [$command], $command); + + if ($result['exit_code'] !== 0 && $required) { + Log::error('PHP-FPM service reload failed', [ + 'php_version' => $phpVersion, + 'output' => $result['output'] ?? null, + ]); + + return false; + } + + return true; + } + + private function writePhpFpmPoolConfig(?SSH2 $ssh, HostingAccount $account, string $phpVersion, array $options = []): void + { + $username = $account->username; + $poolConfig = $this->buildPhpFpmPoolConfig($username, array_merge([ + 'memory_limit_mb' => $account->memory_limit_mb ?: 256, + 'process_limit' => $account->process_limit ?: 5, + 'uploads_restricted' => $account->uploadsAreRestricted(), + 'process_priority' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 10 : 0, + 'max_execution_time' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 120 : 300, + 'max_requests' => $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED ? 250 : 500, + ], $options)); + + $poolPath = "/etc/php/{$phpVersion}/fpm/pool.d/{$username}.conf"; + $this->runLocalAdminOperationOrRemote( + $ssh, + 'write-file', + [$poolPath, base64_encode($poolConfig)], + 'echo '.escapeshellarg($poolConfig).' > '.escapeshellarg($poolPath) + ); + } + + private function createPhpFpmPoolForVersion(?SSH2 $ssh, HostingAccount $account, string $phpVersion): void + { + $this->writePhpFpmPoolConfig($ssh, $account, $phpVersion); + $this->reloadPhpFpmService($ssh, $phpVersion); + } + + public function applyAccountResourceProfile(HostingAccount $account, bool $throttled = false): bool + { + $node = $account->node; + if (! $node) { + return false; + } + + $ssh = $this->connect($node); + $phpVersion = $account->php_version ?? '8.4'; + $username = $account->username; + + $this->logDuplicatePhpFpmPools($ssh, $username, $account); + foreach ($this->removePhpFpmPoolsForAccount($ssh, $username) as $removedVersion) { + if ($removedVersion !== $phpVersion) { + $this->reloadPhpFpmService($ssh, $removedVersion, required: false); + } + } + + $memoryLimit = (int) ($account->memory_limit_mb ?: 256); + $processLimit = (int) ($account->process_limit ?: 5); + + if ($throttled) { + $memoryLimit = max(128, (int) floor($memoryLimit * 0.5)); + $processLimit = max(1, min($processLimit, 2)); + } + + $limitsPath = "/etc/security/limits.d/ladill-{$username}.conf"; + $limitsConfig = $this->buildUserLimitsConfig($username, max($processLimit, 1)); + + $this->writePhpFpmPoolConfig($ssh, $account, $phpVersion, [ + 'memory_limit_mb' => $memoryLimit, + 'process_limit' => $processLimit, + 'uploads_restricted' => $account->uploadsAreRestricted(), + 'process_priority' => $throttled ? 10 : 0, + 'max_execution_time' => $throttled ? 120 : 300, + 'max_requests' => $throttled ? 250 : 500, + ]); + + $limitsResult = $this->runLocalAdminOperationOrRemote( + $ssh, + 'write-file', + [$limitsPath, base64_encode($limitsConfig)], + "echo " . escapeshellarg($limitsConfig) . " > " . escapeshellarg($limitsPath) + ); + + return $limitsResult['exit_code'] === 0 + && $this->reloadPhpFpmService($ssh, $phpVersion); + } + + public function syncAccountPackage(HostingAccount $account): bool + { + $node = $account->node; + if (! $node) { + return false; + } + + $ssh = $this->connect($node); + $quotaKb = max((int) $account->requestedDiskGb(), 0) * 1024 * 1024; + $quotaCommand = $quotaKb > 0 + ? "setquota -u {$account->username} {$quotaKb} {$quotaKb} 0 0 /home" + : "setquota -u {$account->username} 0 0 0 0 /home"; + + $quotaResult = $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + [$quotaCommand], + $quotaCommand + ); + + $profileApplied = $this->applyAccountResourceProfile($account, $account->resource_status === HostingAccount::RESOURCE_STATUS_THROTTLED); + + return $profileApplied && $quotaResult['exit_code'] === 0; + } + + private function buildPhpFpmPoolConfig(string $username, array $options = []): string + { + $memoryLimitMb = max((int) ($options['memory_limit_mb'] ?? 256), 64); + $processLimit = max((int) ($options['process_limit'] ?? 5), 1); + $processPriority = (int) ($options['process_priority'] ?? 0); + $maxExecutionTime = max((int) ($options['max_execution_time'] ?? 300), 30); + $maxRequests = max((int) ($options['max_requests'] ?? 500), 50); + $uploadsRestricted = (bool) ($options['uploads_restricted'] ?? false); + $uploadLimitMb = $uploadsRestricted ? 1 : 64; + $postLimitMb = $uploadsRestricted ? 1 : 64; + $socketPath = $this->phpFpmSocketPath($username); + + return <<account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + $domain = $site->domain; + + try { + $this->applyManagedSiteConfig($ssh, $site, $documentRoot, $username, [ + 'php_version' => $site->php_version ?? '8.4', + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + + $site->update(['document_root' => $documentRoot]); + + if ($site->ssl_enabled && ! $this->siteHasCertificate($ssh, $domain)) { + try { + $this->requestLetsEncryptCertificate($site->fresh()); + } catch (\Exception $e) { + Log::warning("Failed to re-request SSL after document root update for {$domain}: " . $e->getMessage()); + } + } + + return true; + } catch (\Throwable $e) { + Log::error("Nginx config test failed after document root update for {$domain}", [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Configure nginx as a reverse proxy for Node.js or Python apps + */ + public function configureNginxReverseProxy(HostedSite $site, int $port, string $appType = 'nodejs'): bool + { + $account = $site->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + $domain = $site->domain; + $sslExists = $this->siteHasCertificate($ssh, $domain); + $site->forceFill([ + 'installed_app' => $site->installed_app ?: $appType, + 'app_config' => array_merge((array) ($site->app_config ?? []), [ + 'runtime_port' => $port, + 'runtime_type' => $appType, + ]), + ]); + + try { + $this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [ + 'ssl_enabled' => $sslExists, + 'proxy_port' => $port, + 'app_type' => $appType, + ]); + + if ($site->ssl_enabled && ! $sslExists) { + try { + $this->requestLetsEncryptCertificate($site); + + return $this->configureNginxReverseProxy($site, $port, $appType); + } catch (\Exception $e) { + Log::warning("Failed to request SSL after proxy config for {$domain}: " . $e->getMessage()); + } + } + + return true; + } catch (\Throwable $e) { + Log::error("Nginx config test failed after proxy config for {$domain}", [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Create and start a systemd service for a Node.js or Python app + */ + public function createAppService(HostedSite $site, string $appPath, string $appType, int $port): bool + { + $account = $site->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + $domain = $site->domain; + $serviceName = "app-{$username}-" . preg_replace('/[^a-z0-9]/', '-', strtolower($domain)); + + if ($appType === 'nodejs') { + $execStart = "/usr/bin/node {$appPath}/app.js"; + $environment = "PORT={$port}"; + } else { + $execStart = "{$appPath}/venv/bin/python {$appPath}/app.py"; + $environment = "PORT={$port}"; + } + + $serviceContent = <<runLocalAdminOperationOrRemote($ssh, 'write-file', [$servicePath, base64_encode($serviceContent)], "echo '{$escapedService}' > " . escapeshellarg($servicePath)); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl daemon-reload"], "systemctl daemon-reload"); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl enable {$serviceName}"], "systemctl enable {$serviceName}"); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl start {$serviceName}"], "systemctl start {$serviceName}"); + + // Verify service started + $statusResult = $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl is-active {$serviceName}"], "systemctl is-active {$serviceName}"); + + return trim($statusResult['output'] ?? '') === 'active'; + } + + /** + * Remove a systemd service for a Node.js or Python app and restore nginx to PHP-FPM config + */ + public function removeAppService(HostedSite $site): bool + { + $account = $site->account; + $node = $account->node; + if (!$node) { + return false; + } + + $ssh = $this->connect($node); + $username = $account->username; + $domain = $site->domain; + $serviceName = "app-{$username}-" . preg_replace('/[^a-z0-9]/', '-', strtolower($domain)); + + // Stop and disable the systemd service + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl stop {$serviceName} 2>/dev/null"], "systemctl stop {$serviceName} 2>/dev/null"); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl disable {$serviceName} 2>/dev/null"], "systemctl disable {$serviceName} 2>/dev/null"); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["rm -f /etc/systemd/system/{$serviceName}.service"], "rm -f /etc/systemd/system/{$serviceName}.service"); + $this->runLocalAdminOperationOrRemote($ssh, 'run-cmd', ["systemctl daemon-reload"], "systemctl daemon-reload"); + + // Restore nginx config to PHP-FPM + $docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}"; + try { + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [ + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + 'proxy_port' => null, + 'app_type' => null, + ]); + + if ($site->ssl_enabled && ! $this->siteHasCertificate($ssh, $domain)) { + try { + $this->requestLetsEncryptCertificate($site); + } catch (\Exception $e) { + Log::warning("Failed to re-request SSL after app removal for {$domain}: " . $e->getMessage()); + } + } + + return true; + } catch (\Throwable $e) { + Log::error("Nginx config test failed after app removal for {$domain}", [ + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + public function restoreDefaultSiteConfig(HostingAccount $account, HostedSite $site, string $docRoot): void + { + $node = $account->node; + if (! $node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + + $this->applyManagedSiteConfig($ssh, $site, $docRoot, $account->username, [ + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + } + + public function getUsageStats(HostingAccount $account): array + { + $node = $account->node; + if (!$node) { + return []; + } + + $ssh = $this->connect($node); + $username = $account->username; + $homeDir = "/home/{$username}"; + + // Get disk usage + $diskUsage = trim($this->exec($ssh, "du -sb {$homeDir} 2>/dev/null | cut -f1")); + $inodeCount = trim($this->exec($ssh, "find {$homeDir} -xdev 2>/dev/null | wc -l")); + $cpuUsage = trim($this->exec($ssh, "ps -u {$username} -o %cpu= 2>/dev/null | awk '{sum += \$1} END {printf \"%.2f\", sum+0}'")); + $memoryUsage = trim($this->exec($ssh, "ps -u {$username} -o rss= 2>/dev/null | awk '{sum += \$1} END {printf \"%d\", int(sum/1024)}'")); + $processCount = trim($this->exec($ssh, "ps -u {$username} --no-headers 2>/dev/null | wc -l")); + + // Get database sizes + $dbSize = 0; + foreach ($account->databases as $db) { + $size = trim($this->exec($ssh, "mysql -N -e \"SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = '{$db->name}'\" 2>/dev/null")); + $dbSize += (int) $size; + } + + return [ + 'disk_used_bytes' => (int) $diskUsage + $dbSize, + 'database_size_bytes' => $dbSize, + 'file_size_bytes' => (int) $diskUsage, + 'inode_count' => (int) $inodeCount, + 'cpu_usage_percent' => is_numeric($cpuUsage) ? round((float) $cpuUsage, 2) : null, + 'memory_used_mb' => is_numeric($memoryUsage) ? (int) $memoryUsage : 0, + 'process_count' => is_numeric($processCount) ? max(((int) $processCount) - 1, 0) : 0, + ]; + } + + public function executeCommand(HostingAccount $account, string $command, int $timeout = 600): array + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $username = $account->username; + + // For local nodes, run commands directly as root (since we're on the server) + if ($this->isLocalNode) { + return $this->runLocalAccountCommand($username, $command, $timeout); + } else { + // For remote nodes, use runuser + $output = $this->exec($ssh, "runuser -u {$username} -- bash -c " . escapeshellarg($command)); + $exitCode = $this->getExitStatus($ssh); + } + + return [ + 'output' => $output, + 'exit_code' => $exitCode, + ]; + } + + public function executeTerminalCommand(HostingAccount $account, string $command, string $workingDirectory, int $timeout = 300): array + { + $node = $account->node; + if (! $node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $command = trim($command); + if ($command === '') { + return [ + 'output' => 'Command is required.', + 'exit_code' => 64, + ]; + } + + $this->assertValidTerminalCommand($command); + + $username = $account->username; + $homeDirectory = "/home/{$username}"; + if (! str_starts_with($workingDirectory, $homeDirectory.'/') && $workingDirectory !== $homeDirectory) { + throw new \RuntimeException('Terminal working directory must stay within the hosting account home directory.'); + } + + $ssh = $this->connect($node); + $sandboxCommand = $this->buildTerminalSandboxCommand($username, $workingDirectory, $command, $timeout); + + if ($this->isLocalNode) { + $sudoCommand = "sudo -n -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand); + $result = $this->runLocalProcess($sudoCommand, $timeout + 15); + + if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('sudo', $result['output'])) { + return $result; + } + + $runUserCommand = "runuser -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand); + $result = $this->runLocalProcess($runUserCommand, $timeout + 15); + + if ($result['exit_code'] === 0 || ! $this->isLocalUserSwitchFailure('runuser', $result['output'])) { + return $result; + } + + return [ + 'output' => $this->localUserSwitchUnavailableMessage(), + 'exit_code' => 1, + ]; + } + + $output = $this->exec($ssh, "runuser -u {$username} -- bash -lc " . escapeshellarg($sandboxCommand)); + + return [ + 'output' => $output, + 'exit_code' => $this->getExitStatus($ssh), + ]; + } + + public function executeCommandAsRoot(HostingAccount $account, string $command): array + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + + if ($this->isLocalNode) { + return $this->runLocalProcess($command); + } else { + $output = $this->exec($ssh, $command); + $exitCode = $this->getExitStatus($ssh); + } + + return [ + 'output' => $output, + 'exit_code' => $exitCode, + ]; + } + + public function runWpCli(HostingAccount $account, string $path, string $wpCommand): array + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $username = $account->username; + $ssh = $this->connect($node); + + // Build the WP-CLI command to run as the hosting user + $fullCommand = "cd " . escapeshellarg($path) . " && wp {$wpCommand}"; + + if ($this->isLocalNode) { + // For local node, use sudo to run as the hosting user + // www-data needs passwordless sudo for this to work + $sudoCommand = "sudo -n -u " . escapeshellarg($username) . " bash -c " . escapeshellarg($fullCommand); + return $this->runLocalProcess($sudoCommand); + } else { + // Remote: use runuser + $output = $this->exec($ssh, "runuser -u {$username} -- bash -c " . escapeshellarg($fullCommand)); + return [ + 'output' => $output, + 'exit_code' => $this->getExitStatus($ssh), + ]; + } + } + + private function ensureAdministrativeCommandSucceeded(?SSH2 $ssh, string $command, string $context): void + { + $result = $this->executeAdministrativeCommand($ssh, $command); + + if ($result['exit_code'] !== 0) { + throw new \RuntimeException(trim((string) $result['output']) ?: $context); + } + } + + private function ensureAdministrativeResultSucceeded(array $result, string $context): void + { + if ($result['exit_code'] !== 0) { + throw new \RuntimeException(trim((string) $result['output']) ?: $context); + } + } + + private function runMagentoOpenSearchStateCheck(?SSH2 $ssh): void + { + $result = $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ['/usr/local/bin/check-opensearch-for-magento.sh'], + '/usr/local/bin/check-opensearch-for-magento.sh' + ); + + if (($result['exit_code'] ?? 1) !== 0) { + Log::warning('Failed to refresh Magento OpenSearch state after hosting change.', [ + 'output' => trim((string) ($result['output'] ?? '')), + ]); + } + } + + public function runLocalAdminOperationOrRemote(?SSH2 $ssh, string $operation, array $arguments, string $remoteCommand): array + { + if ($this->isLocalNode) { + return $this->executeLocalAdminOperation( + $operation, + $arguments, + fn (): array => $this->executeAdministrativeCommand($ssh, $remoteCommand) + ); + } + + return $this->executeAdministrativeCommand($ssh, $remoteCommand); + } + + public function prepareDirectoryForUser(HostingAccount $account, string $path): void + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $username = $account->username; + $quotedPath = escapeshellarg($path); + $userLevelResult = $this->executeCommand($account, "mkdir -p {$quotedPath} && test -w {$quotedPath}"); + + if ($userLevelResult['exit_code'] === 0) { + return; + } + + if (trim((string) $userLevelResult['output']) === $this->localUserSwitchUnavailableMessage()) { + throw new \RuntimeException($this->localUserSwitchUnavailableMessage()); + } + + $commands = [ + ['mkdir', [$path], "mkdir -p {$quotedPath}"], + ['chown', ["{$username}:{$username}", $path], "chown -R {$username}:{$username} {$quotedPath}"], + ['chmod', ['u+rwx', $path], "chmod u+rwx {$quotedPath}"], + ]; + + foreach ($commands as [$operation, $arguments, $remoteCommand]) { + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, $operation, $arguments, $remoteCommand), + "Failed to prepare directory: {$path}" + ); + } + } + + public function setWebServerGroupOwnership(HostingAccount $account, string $path): void + { + $node = $account->node; + if (!$node) { + throw new \RuntimeException('No hosting node assigned'); + } + + $ssh = $this->connect($node); + $quotedPath = escapeshellarg($path); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'chgrp', [self::WEB_SERVER_GROUP, $path], "chgrp " . self::WEB_SERVER_GROUP . " {$quotedPath}"), + "Failed to set web server group ownership: {$path}" + ); + } + + private function hardenAccountFilesystemPaths(?SSH2 $ssh, string $username, array $documentRoots = []): void + { + $sharedHomeDir = '/home'; + $homeDir = "/home/{$username}"; + $publicHtml = "{$homeDir}/public_html"; + $logsDir = "{$homeDir}/logs"; + $tmpDir = "{$homeDir}/tmp"; + $backupsDir = "{$homeDir}/backups"; + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -d {$sharedHomeDir} || exit 0; chown root:root {$sharedHomeDir} && chmod 0711 {$sharedHomeDir}"], + "test -d {$sharedHomeDir} || exit 0; chown root:root {$sharedHomeDir} && chmod 0711 {$sharedHomeDir}" + ), + 'Failed to secure shared home directory' + ); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -d {$homeDir} || exit 0; chown root:root {$homeDir} && chmod 0755 {$homeDir}"], + "test -d {$homeDir} || exit 0; chown root:root {$homeDir} && chmod 0755 {$homeDir}" + ), + "Failed to secure account jail root: {$homeDir}" + ); + + $ownershipPaths = array_values(array_unique(array_filter(array_merge([ + $publicHtml, + $logsDir, + $tmpDir, + $backupsDir, + ], $documentRoots)))); + + foreach ($ownershipPaths as $path) { + $quotedPath = escapeshellarg($path); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -e {$quotedPath} || exit 0; chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedPath}"], + "test -e {$quotedPath} || exit 0; chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedPath}" + ), + "Failed to set secure ownership for {$path}" + ); + } + + foreach ([ + $publicHtml => '2750', + $logsDir => '2770', + $tmpDir => '1770', + $backupsDir => '2750', + ] as $path => $mode) { + $quotedPath = escapeshellarg($path); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -d {$quotedPath} || exit 0; chmod {$mode} {$quotedPath}"], + "test -d {$quotedPath} || exit 0; chmod {$mode} {$quotedPath}" + ), + "Failed to set secure permissions for {$path}" + ); + } + + foreach (array_unique(array_filter($documentRoots)) as $path) { + $quotedPath = escapeshellarg($path); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -d {$quotedPath} || exit 0; chmod 2750 {$quotedPath}"], + "test -d {$quotedPath} || exit 0; chmod 2750 {$quotedPath}" + ), + "Failed to set secure document root permissions for {$path}" + ); + } + } + + private function ensureSharedSftpIsolationPolicy(?SSH2 $ssh, string $username): void + { + $authorizedKeysPath = $this->sharedAccountAuthorizedKeysPath($username); + $legacyAuthorizedKeysPath = "/home/{$username}/.ssh/authorized_keys"; + $sshdConfig = implode("\n", [ + '# Managed by Ladill Hosting', + '# Shared hosting accounts are confined to SFTP inside their account jail.', + 'Match Group '.self::SFTP_ONLY_GROUP, + ' ChrootDirectory %h', + ' ForceCommand internal-sftp', + ' AuthorizedKeysFile '.self::SSH_AUTHORIZED_KEYS_DIR.'/%u', + ' PasswordAuthentication yes', + ' PubkeyAuthentication yes', + ' PermitTTY no', + ' AllowTcpForwarding no', + ' X11Forwarding no', + ' PermitTunnel no', + ' GatewayPorts no', + '', + ]); + + $setupCommand = implode("\n", [ + 'set -e', + 'if ! grep -Eq '.escapeshellarg('^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\\.d/\\*\\.conf([[:space:]]|$)').' '.escapeshellarg(self::SSHD_MAIN_CONFIG).'; then', + ' printf "\nInclude /etc/ssh/sshd_config.d/*.conf\n" >> '.escapeshellarg(self::SSHD_MAIN_CONFIG), + 'fi', + 'mkdir -p /etc/ssh/sshd_config.d '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), + 'getent group '.escapeshellarg(self::SFTP_ONLY_GROUP).' >/dev/null 2>&1 || groupadd --system '.escapeshellarg(self::SFTP_ONLY_GROUP), + 'usermod -a -G '.escapeshellarg(self::SFTP_ONLY_GROUP).' '.escapeshellarg($username), + 'NOLOGIN_SHELL="$(command -v nologin || true)"', + 'if [ -z "$NOLOGIN_SHELL" ]; then', + ' for candidate in /usr/sbin/nologin /sbin/nologin /bin/false; do', + ' if [ -x "$candidate" ]; then', + ' NOLOGIN_SHELL="$candidate"', + ' break', + ' fi', + ' done', + 'fi', + 'if [ -z "$NOLOGIN_SHELL" ]; then', + ' echo "No nologin shell available for shared account confinement." >&2', + ' exit 1', + 'fi', + 'CURRENT_SHELL="$(getent passwd '.escapeshellarg($username).' | cut -d: -f7)"', + 'if [ "$CURRENT_SHELL" != "$NOLOGIN_SHELL" ]; then', + ' usermod -s "$NOLOGIN_SHELL" '.escapeshellarg($username), + 'fi', + 'touch '.escapeshellarg($authorizedKeysPath), + 'if [ -f '.escapeshellarg($legacyAuthorizedKeysPath).' ]; then cat '.escapeshellarg($legacyAuthorizedKeysPath).' >> '.escapeshellarg($authorizedKeysPath).'; fi', + 'awk \'NF && !seen[$0]++\' '.escapeshellarg($authorizedKeysPath).' > '.escapeshellarg("{$authorizedKeysPath}.tmp"), + 'mv '.escapeshellarg("{$authorizedKeysPath}.tmp").' '.escapeshellarg($authorizedKeysPath), + 'chown root:root '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR).' '.escapeshellarg($authorizedKeysPath), + 'chmod 0755 '.escapeshellarg(self::SSH_AUTHORIZED_KEYS_DIR), + 'chmod 0600 '.escapeshellarg($authorizedKeysPath), + ]); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + [$setupCommand], + $setupCommand + ), + 'Failed to prepare shared account SSH isolation policy' + ); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'write-file', + [self::SSHD_SHARED_HOSTING_CONFIG, base64_encode($sshdConfig)], + "printf %s ".escapeshellarg(base64_encode($sshdConfig))." | base64 -d > ".escapeshellarg(self::SSHD_SHARED_HOSTING_CONFIG) + ), + 'Failed to write shared hosting SSH policy' + ); + + $reloadCommand = 'sshd -t && (systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || service ssh reload 2>/dev/null || service sshd reload 2>/dev/null)'; + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + [$reloadCommand], + $reloadCommand + ), + 'Failed to validate and reload SSH service' + ); + } + + private function sharedAccountAuthorizedKeysPath(string $username): string + { + return self::SSH_AUTHORIZED_KEYS_DIR."/{$username}"; + } + + private function assertValidTerminalCommand(string $command): void + { + if (strlen($command) > 500) { + throw new \RuntimeException('Terminal command is too long.'); + } + + if (preg_match('/[\r\n]/', $command) === 1) { + throw new \RuntimeException('Terminal commands must be a single line.'); + } + + if (preg_match('/(?:\|\||&&|[|;<>`$])/', $command) === 1) { + throw new \RuntimeException('Terminal commands cannot use shell chaining, pipes, or redirection.'); + } + + $token = strtok($command, " \t"); + if ($token === false || $token === '') { + throw new \RuntimeException('Terminal command is required.'); + } + + if (str_contains($token, '..') || str_starts_with($token, '/')) { + throw new \RuntimeException('Terminal command executable is not allowed.'); + } + + $executable = basename(ltrim($token, './')); + if (! in_array($executable, self::TERMINAL_ALLOWED_EXECUTABLES, true)) { + throw new \RuntimeException("`{$executable}` is not available in the panel terminal."); + } + } + + private function buildTerminalSandboxCommand(string $username, string $workingDirectory, string $command, int $timeout): string + { + $homeDirectory = "/home/{$username}"; + $commandPayload = base64_encode($command); + $networkTimeout = max(30, min($timeout, 600)); + + return implode("\n", [ + 'set -eu', + 'if ! command -v bwrap >/dev/null 2>&1; then', + " echo 'Browser terminal requires bubblewrap on the hosting node.' >&2", + ' exit 127', + 'fi', + 'HOME_DIR='.escapeshellarg($homeDirectory), + 'WORKDIR='.escapeshellarg($workingDirectory), + 'COMMAND_B64='.escapeshellarg($commandPayload), + 'COMMAND="$(printf %s "$COMMAND_B64" | base64 --decode)"', + 'if [ ! -d "$WORKDIR" ]; then', + " echo 'Working directory does not exist.' >&2", + ' exit 1', + 'fi', + 'export HOME="$HOME_DIR"', + 'export USER='.escapeshellarg($username), + 'export LOGNAME='.escapeshellarg($username), + 'export TMPDIR=/tmp', + 'export PATH=/usr/local/bin:/usr/bin:/bin', + 'export LANG=C.UTF-8', + 'export TERM=xterm-256color', + 'BWRAP_ARGS=(', + ' --die-with-parent', + ' --new-session', + ' --unshare-pid', + ' --share-net', + ' --proc /proc', + ' --dev /dev', + ' --dir /etc', + ' --dir /run', + ' --bind "$HOME_DIR" "$HOME_DIR"', + ' --bind "$HOME_DIR/tmp" /tmp', + ' --chdir "$WORKDIR"', + ')', + '[ -d /usr ] && BWRAP_ARGS+=(--ro-bind /usr /usr)', + '[ -d /usr/local ] && BWRAP_ARGS+=(--ro-bind /usr/local /usr/local)', + '[ -d /bin ] && BWRAP_ARGS+=(--ro-bind /bin /bin)', + '[ -d /lib ] && BWRAP_ARGS+=(--ro-bind /lib /lib)', + '[ -d /lib64 ] && BWRAP_ARGS+=(--ro-bind /lib64 /lib64)', + '[ -f /etc/resolv.conf ] && BWRAP_ARGS+=(--ro-bind /etc/resolv.conf /etc/resolv.conf)', + '[ -f /etc/hosts ] && BWRAP_ARGS+=(--ro-bind /etc/hosts /etc/hosts)', + '[ -f /etc/nsswitch.conf ] && BWRAP_ARGS+=(--ro-bind /etc/nsswitch.conf /etc/nsswitch.conf)', + '[ -f /etc/passwd ] && BWRAP_ARGS+=(--ro-bind /etc/passwd /etc/passwd)', + '[ -f /etc/group ] && BWRAP_ARGS+=(--ro-bind /etc/group /etc/group)', + '[ -d /etc/ssl ] && BWRAP_ARGS+=(--ro-bind /etc/ssl /etc/ssl)', + '[ -d /run/mysqld ] && BWRAP_ARGS+=(--ro-bind /run/mysqld /run/mysqld)', + 'ulimit -t '.max(15, min($networkTimeout, 120)).' 2>/dev/null || true', + 'ulimit -u 64 2>/dev/null || true', + 'ulimit -v 1048576 2>/dev/null || true', + 'ulimit -f 204800 2>/dev/null || true', + 'exec timeout -k 5 '.(int) $networkTimeout.' bwrap "${BWRAP_ARGS[@]}" bash -lc "$COMMAND"', + ]); + } + + /** + * Ensure PHP-FPM pool exists for the account, creating it if missing + */ + public function ensurePhpFpmPool(HostingAccount $account): void + { + $node = $account->node; + if (! $node) { + return; + } + + $ssh = $this->connect($node); + $phpVersion = $account->php_version ?? '8.4'; + + $this->ensureSinglePhpFpmPool($ssh, $account, $phpVersion); + + $socketCheck = $this->executeAdministrativeCommand( + $ssh, + 'test -S '.escapeshellarg($this->phpFpmSocketPath($account->username)).' && echo exists' + ); + + if (! str_contains((string) ($socketCheck['output'] ?? ''), 'exists')) { + $this->reloadPhpFpmService($ssh, $phpVersion); + } + } + + private function createPhpFpmPool(?SSH2 $ssh, HostingAccount $account): void + { + $phpVersion = $account->php_version ?? '8.4'; + $this->createPhpFpmPoolForVersion($ssh, $account, $phpVersion); + } + + public function generateNginxConfig(HostedSite $site, string $docRoot, string $username, ?string $phpVersion = null, ?SSH2 $ssh = null): string + { + return $this->renderManagedNginxConfig($site, $docRoot, $username, [ + 'php_version' => $phpVersion, + 'ssl_enabled' => $this->shouldRenderSslForSite($site, $ssh), + ]); + } + + private function applyManagedSiteConfig(?SSH2 $ssh, HostedSite $site, string $docRoot, string $username, array $options = []): void + { + $config = $this->renderManagedNginxConfig($site, $docRoot, $username, $options); + [$configPath, $enabledPath] = $this->nginxConfigPaths($site->domain); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'write-file', + [$configPath, base64_encode($config)], + "printf %s " . escapeshellarg(base64_encode($config)) . " | base64 -d > " . escapeshellarg($configPath) + ), + "Failed to write nginx configuration for {$site->domain}" + ); + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote( + $ssh, + 'symlink', + [$configPath, $enabledPath], + "ln -sf " . escapeshellarg($configPath) . ' ' . escapeshellarg($enabledPath) + ), + "Failed to enable nginx configuration for {$site->domain}" + ); + + $testResult = $this->runLocalAdminOperationOrRemote($ssh, 'nginx-test', [], 'nginx -t 2>&1'); + if ($testResult['exit_code'] !== 0 || strpos((string) ($testResult['output'] ?? ''), 'successful') === false) { + Log::error("Nginx config test failed for {$site->domain}", ['output' => $testResult]); + throw new \RuntimeException("Invalid nginx configuration for {$site->domain}"); + } + + $this->ensureAdministrativeResultSucceeded( + $this->runLocalAdminOperationOrRemote($ssh, 'reload-nginx', [], 'systemctl reload nginx'), + "Failed to reload nginx for {$site->domain}" + ); + } + + private function renderManagedNginxConfig(HostedSite $site, string $docRoot, string $username, array $options = []): string + { + $domain = $site->domain; + $sslEnabled = (bool) ($options['ssl_enabled'] ?? false); + $proxyPort = $this->resolveProxyPort($site, $options); + $appType = $options['app_type'] ?? $site->installed_app ?? 'php'; + $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; + $header = sprintf( + self::NGINX_CONFIG_HEADER, + $domain, + $username, + $proxyPort !== null ? "proxy:{$appType}" : 'php' + ); + $acmeLocation = <<storedSslState($site); + + if ($ssh !== null) { + $certificateState = $this->probeSiteCertificateState($ssh, $site->domain); + + return $certificateState ?? $persistedSslState; + } + + return $persistedSslState; + } + + private function siteHasCertificate(?SSH2 $ssh, string $domain): bool + { + return $this->probeSiteCertificateState($ssh, $domain) === true; + } + + private function probeSiteCertificateState(?SSH2 $ssh, string $domain): ?bool + { + $certPath = "/etc/letsencrypt/live/{$domain}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domain}/privkey.pem"; + $certCheckResult = $this->runLocalAdminOperationOrRemote( + $ssh, + 'run-cmd', + ["test -f {$certPath} && test -f {$keyPath} && echo 'exists'"], + "test -f {$certPath} && test -f {$keyPath} && echo 'exists'" + ); + + if (($certCheckResult['exit_code'] ?? 1) !== 0) { + Log::warning('SSL: Certificate presence check failed, falling back to persisted site state.', [ + 'domain' => $domain, + 'output' => $certCheckResult['output'] ?? null, + 'exit_code' => $certCheckResult['exit_code'] ?? null, + ]); + + return null; + } + + return trim((string) ($certCheckResult['output'] ?? '')) === 'exists'; + } + + private function storedSslState(HostedSite $site): bool + { + return (bool) ($site->ssl_enabled || $site->ssl_status === 'issued'); + } + + private function resolveProxyPort(HostedSite $site, array $options = []): ?int + { + if (array_key_exists('proxy_port', $options)) { + return $options['proxy_port'] !== null ? (int) $options['proxy_port'] : null; + } + + $runtimePort = data_get($site->app_config, 'runtime_port'); + if (is_numeric($runtimePort)) { + return (int) $runtimePort; + } + + $appType = array_key_exists('app_type', $options) ? $options['app_type'] : $site->installed_app; + if (! $site->id) { + return null; + } + + return match ($appType) { + 'nodejs' => 3000 + ($site->id % 1000), + 'python' => 5000 + ($site->id % 1000), + default => null, + }; + } + + private function nginxConfigPaths(string $domain): array + { + return [ + "/etc/nginx/sites-available/{$domain}.conf", + "/etc/nginx/sites-enabled/{$domain}.conf", + ]; + } + + /** + * Migrate an account from its current node to a destination node + */ + public function migrateAccount(HostingAccount $account, HostingNode $destinationNode, callable $progressCallback = null): array + { + $sourceNode = $account->node; + if (!$sourceNode) { + throw new \RuntimeException('Account has no source node'); + } + + if ($sourceNode->id === $destinationNode->id) { + throw new \RuntimeException('Source and destination nodes are the same'); + } + + $username = $account->username; + $homeDir = "/home/{$username}"; + $timestamp = time(); + $archivePath = "/tmp/{$username}_migration_{$timestamp}.tar.gz"; + $phpVersion = $account->php_version ?? '8.4'; + + $progress = function ($message) use ($progressCallback) { + Log::info("Migration: {$message}"); + if ($progressCallback) { + $progressCallback($message); + } + }; + + try { + // Step 1: Create archive on source node + $progress("Creating archive on source node..."); + $sourceSsh = $this->connect($sourceNode); + + $this->exec($sourceSsh, "tar -czf {$archivePath} -C /home {$username}"); + + // Export databases + $dbDumps = []; + foreach ($account->databases as $db) { + $dumpPath = "/tmp/{$db->name}_migration_{$timestamp}.sql"; + $this->exec($sourceSsh, "mysqldump --single-transaction {$db->name} > {$dumpPath}"); + $dbDumps[$db->name] = $dumpPath; + } + + // Step 2: Transfer files to destination + $progress("Transferring files to destination node..."); + + $sourceIsLocal = $this->isLocal($sourceNode); + $destIsLocal = $this->isLocal($destinationNode); + + if ($sourceIsLocal && $destIsLocal) { + // Both local - files already in place + $progress("Both nodes are local, no transfer needed"); + } elseif ($sourceIsLocal) { + // Source local, destination remote + $keyFile = $this->writeKeyToTempFile($destinationNode->ssh_private_key); + Process::run("scp -i {$keyFile} -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$archivePath} root@{$destinationNode->ip_address}:{$archivePath}"); + foreach ($dbDumps as $dbName => $dumpPath) { + Process::run("scp -i {$keyFile} -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$dumpPath} root@{$destinationNode->ip_address}:{$dumpPath}"); + } + @unlink($keyFile); + } elseif ($destIsLocal) { + // Source remote, destination local + $keyFile = $this->writeKeyToTempFile($sourceNode->ssh_private_key); + Process::run("scp -i {$keyFile} -P {$sourceNode->ssh_port} -o StrictHostKeyChecking=no root@{$sourceNode->ip_address}:{$archivePath} {$archivePath}"); + foreach ($dbDumps as $dbName => $dumpPath) { + Process::run("scp -i {$keyFile} -P {$sourceNode->ssh_port} -o StrictHostKeyChecking=no root@{$sourceNode->ip_address}:{$dumpPath} {$dumpPath}"); + } + @unlink($keyFile); + } else { + // Both remote - transfer via source + $keyFile = $this->writeKeyToTempFile($destinationNode->ssh_private_key); + $this->exec($sourceSsh, "scp -i /tmp/dest_key -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$archivePath} root@{$destinationNode->ip_address}:{$archivePath}"); + foreach ($dbDumps as $dbName => $dumpPath) { + $this->exec($sourceSsh, "scp -i /tmp/dest_key -P {$destinationNode->ssh_port} -o StrictHostKeyChecking=no {$dumpPath} root@{$destinationNode->ip_address}:{$dumpPath}"); + } + } + + // Step 3: Create user on destination + $progress("Creating user on destination node..."); + $destSsh = $this->connect($destinationNode); + $newPassword = Str::password(16, true, true, false, false); + + $this->exec($destSsh, "useradd -m -d {$homeDir} -s /bin/bash {$username} 2>/dev/null || true"); + $this->exec($destSsh, "echo '{$username}:{$newPassword}' | chpasswd"); + + // Step 4: Extract archive on destination + $progress("Extracting files on destination node..."); + $this->exec($destSsh, "rm -rf {$homeDir}/* 2>/dev/null || true"); + $this->exec($destSsh, "tar -xzf {$archivePath} -C /home --overwrite"); + $this->exec($destSsh, "chown -R {$username}:{$username} {$homeDir}"); + + // Step 5: Restore databases + $progress("Restoring databases..."); + foreach ($account->databases as $db) { + $dumpPath = "/tmp/{$db->name}_migration_{$timestamp}.sql"; + $this->exec($destSsh, "mysql -e \"CREATE DATABASE IF NOT EXISTS \\`{$db->name}\\`\""); + $this->exec($destSsh, "mysql -e \"CREATE USER IF NOT EXISTS '{$db->username}'@'localhost' IDENTIFIED BY 'temp_pass'\""); + $this->exec($destSsh, "mysql -e \"GRANT ALL PRIVILEGES ON \\`{$db->name}\\`.* TO '{$db->username}'@'localhost'\""); + $this->exec($destSsh, "mysql {$db->name} < {$dumpPath}"); + $this->exec($destSsh, "rm -f {$dumpPath}"); + } + + // Step 6: Create PHP-FPM pool on destination + $progress("Creating PHP-FPM pool..."); + $this->createPhpFpmPoolForUser($destSsh, $username, $phpVersion); + + // Step 7: Create nginx configs for all sites + $progress("Creating nginx configurations..."); + foreach ($account->sites as $site) { + $docRoot = $site->document_root ?: "/home/{$username}/public_html"; + $this->createNginxConfigForDomain($destSsh, $site->domain, $docRoot, $username); + } + + $this->hardenAccountFilesystemPaths( + $destSsh, + $username, + $account->sites->pluck('document_root')->filter()->values()->all() + ); + + // If no sites, create config for primary domain + if ($account->sites->isEmpty() && $account->domain) { + $this->createNginxConfigForDomain($destSsh, $account->domain, "/home/{$username}/public_html", $username); + } + + // Step 8: Cleanup source node + $progress("Cleaning up source node..."); + $sourceSsh = $this->connect($sourceNode); + + // Remove nginx configs + foreach ($account->sites as $site) { + $this->exec($sourceSsh, "rm -f /etc/nginx/sites-enabled/{$site->domain}.conf"); + $this->exec($sourceSsh, "rm -f /etc/nginx/sites-available/{$site->domain}.conf"); + } + if ($account->domain) { + $this->exec($sourceSsh, "rm -f /etc/nginx/sites-enabled/{$account->domain}.conf"); + $this->exec($sourceSsh, "rm -f /etc/nginx/sites-available/{$account->domain}.conf"); + } + + // Remove PHP-FPM pool + $this->exec($sourceSsh, "rm -f /etc/php/*/fpm/pool.d/{$username}.conf"); + + // Drop databases on source + foreach ($account->databases as $db) { + $this->exec($sourceSsh, "mysql -e \"DROP DATABASE IF EXISTS \\`{$db->name}\\`\""); + $this->exec($sourceSsh, "mysql -e \"DROP USER IF EXISTS '{$db->username}'@'localhost'\""); + } + + // Remove user on source + $this->exec($sourceSsh, "userdel -r {$username} 2>/dev/null || true"); + + // Reload services on source + $this->exec($sourceSsh, "nginx -t && systemctl reload nginx"); + $this->exec($sourceSsh, "systemctl reload php*-fpm"); + + // Cleanup temp files + $this->exec($sourceSsh, "rm -f {$archivePath}"); + foreach ($dbDumps as $dumpPath) { + $this->exec($sourceSsh, "rm -f {$dumpPath}"); + } + + // Cleanup on destination + $destSsh = $this->connect($destinationNode); + $this->exec($destSsh, "rm -f {$archivePath}"); + + $progress("Migration completed successfully!"); + + return [ + 'success' => true, + 'new_password' => $newPassword, + 'source_node' => $sourceNode->name, + 'destination_node' => $destinationNode->name, + ]; + + } catch (\Throwable $e) { + Log::error("Migration failed for {$username}: " . $e->getMessage()); + throw new \RuntimeException("Migration failed: " . $e->getMessage(), 0, $e); + } + } + + private function writeKeyToTempFile(string $privateKey): string + { + $keyFile = tempnam(sys_get_temp_dir(), 'ssh_key_'); + file_put_contents($keyFile, $privateKey); + chmod($keyFile, 0600); + return $keyFile; + } +} diff --git a/app/Services/Hosting/ResellerClubHostingService.php b/app/Services/Hosting/ResellerClubHostingService.php new file mode 100644 index 0000000..dd0f0a9 --- /dev/null +++ b/app/Services/Hosting/ResellerClubHostingService.php @@ -0,0 +1,662 @@ +apiUrl = rtrim($this->configuredApiUrl(), '/'); + $this->hostingEndpoint = trim((string) config('mailinfra.hosting_endpoint', 'singledomainhosting/linux/us'), '/'); + $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); + $this->apiKey = (string) config('mailinfra.registrar_api_key'); + $this->customerId = (string) config('mailinfra.registrar_customer_id'); + } + + public function isConfigured(): bool + { + return $this->authUserId !== '' && $this->apiKey !== ''; + } + + public function lastSyncError(): ?string + { + return $this->lastSyncError; + } + + /** + * Build a URL with all parameters as query string values, including auth. + * + * @param array $params + */ + private function buildQueryUrl(string $endpoint, array $params = []): string + { + $all = array_merge([ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ], $params); + + $separator = str_contains($endpoint, '?') ? '&' : '?'; + + return $endpoint.$separator.http_build_query($all); + } + + /** + * Authenticate a ResellerClub customer by email and password. + * Returns the customer ID on success, or null on failure. + * + * @return array{success: bool, customer_id?: string, message?: string} + */ + public function authenticateCustomer(string $email, string $password): array + { + try { + $response = Http::timeout(15)->post($this->buildQueryUrl($this->apiUrl.'/customers/authenticate.json', [ + 'username' => $email, + 'passwd' => $password, + ])); + + $data = $response->json(); + + if ($response->failed()) { + $message = $data['message'] ?? $data['error'] ?? 'Authentication failed.'; + + return ['success' => false, 'message' => $message]; + } + + // RC returns the customer ID directly as the response body on success + $customerId = is_array($data) ? (string) ($data['customerid'] ?? '') : (string) $data; + + if ($customerId === '' || $customerId === 'false') { + return ['success' => false, 'message' => 'Invalid email or password.']; + } + + return ['success' => true, 'customer_id' => $customerId]; + } catch (\Throwable $e) { + Log::error('HostingService: authenticateCustomer failed', ['error' => $e->getMessage()]); + + return ['success' => false, 'message' => 'Could not verify ResellerClub credentials.']; + } + } + + /** + * Get customer details by customer ID. + * + * @return array{success: bool, customer?: array, message?: string} + */ + public function getCustomerDetails(string $customerId): array + { + try { + $response = Http::timeout(15)->get($this->apiUrl.'/customers/details-by-id.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'customer-id' => $customerId, + ]); + + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + return ['success' => false, 'message' => 'Could not fetch customer details.']; + } + + return ['success' => true, 'customer' => $data]; + } catch (\Throwable $e) { + Log::error('HostingService: getCustomerDetails failed', ['error' => $e->getMessage()]); + + return ['success' => false, 'message' => 'Could not fetch customer details.']; + } + } + + /** + * Search hosting orders for a specific customer. + * + * @return array{success: bool, orders?: array, total?: int, message?: string} + */ + public function searchOrdersByCustomer(string $customerId, int $page = 1, int $perPage = 50): array + { + $this->lastSyncError = null; + + try { + $params = [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'no-of-records' => $perPage, + 'page-no' => $page, + 'customer-id' => $customerId, + ]; + + $response = Http::timeout(15)->get($this->endpoint('search'), $params); + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + $message = $this->responseMessage($response, $data, 'Failed to search hosting orders.'); + $this->lastSyncError = $message; + + Log::warning('HostingService: hosting search unsuccessful response', [ + 'customer_id' => $customerId, + 'status' => $response->status(), + 'body' => $this->sanitizeErrorText((string) $response->body()), + ]); + + return ['success' => false, 'message' => $message]; + } + + $total = (int) ($data['recsonpage'] ?? 0); + $orders = []; + $recordCount = (int) ($data['recsindb'] ?? 0); + + for ($i = 1; $i <= $total; $i++) { + if (isset($data[(string) $i])) { + $orders[] = $this->normalizeOrderData($data[(string) $i]); + } + } + + return [ + 'success' => true, + 'orders' => $orders, + 'total' => $recordCount, + ]; + } catch (\Throwable $e) { + Log::error('HostingService: searchOrdersByCustomer failed', ['error' => $e->getMessage()]); + + $message = $this->syncErrorMessage($e, 'Could not fetch hosting orders.'); + $this->lastSyncError = $message; + + return ['success' => false, 'message' => $message]; + } + } + + /** + * Sync all orders for a specific RC customer into a Ladill user account. + * + * @return int Number of orders synced. + */ + public function syncCustomerOrders(string $customerId, int $userId): int + { + $this->lastSyncError = null; + + $page = 1; + $synced = 0; + + do { + $result = $this->searchOrdersByCustomer($customerId, $page, 50); + + if (! ($result['success'] ?? false) || empty($result['orders'])) { + break; + } + + foreach ($result['orders'] as $order) { + $payload = is_array($order) ? ($order['raw'] ?? $order) : []; + $orderId = (string) ($payload['orderid'] ?? $payload['entityid'] ?? $order['order_id'] ?? ''); + + if ($orderId !== '') { + $details = $this->getOrderDetails($orderId); + if (($details['success'] ?? false) && is_array($details['order'] ?? null)) { + $payload = (array) ($details['order']['raw'] ?? $details['order']); + } + } + + $this->syncOrderToLocal((array) $payload, $userId); + $synced++; + } + + $page++; + } while ($synced < ($result['total'] ?? 0)); + + return $synced; + } + + /** + * Search hosting orders under the reseller account. + * + * @return array{success: bool, orders?: array, total?: int, message?: string} + */ + public function searchOrders(int $page = 1, int $perPage = 25, ?string $domainName = null, ?string $status = null): array + { + try { + $params = [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'no-of-records' => $perPage, + 'page-no' => $page, + ]; + + if ($domainName) { + $params['domain-name'] = $domainName; + } + + if ($status) { + $params['status'] = $status; + } + + if ($this->customerId) { + $params['customer-id'] = $this->customerId; + } + + $response = Http::timeout(15)->get($this->endpoint('search'), $params); + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + $message = $this->responseMessage($response, $data, 'Failed to search hosting orders.'); + + Log::warning('HostingService: hosting search unsuccessful response', [ + 'status' => $response->status(), + 'body' => $this->sanitizeErrorText((string) $response->body()), + ]); + + return ['success' => false, 'message' => $message]; + } + + $total = (int) ($data['recsonpage'] ?? 0); + $orders = []; + + // RC returns numbered keys for each record + $recordCount = (int) ($data['recsindb'] ?? 0); + for ($i = 1; $i <= $total; $i++) { + if (isset($data[(string) $i])) { + $orders[] = $this->normalizeOrderData($data[(string) $i]); + } + } + + return [ + 'success' => true, + 'orders' => $orders, + 'total' => $recordCount, + ]; + } catch (\Throwable $e) { + Log::error('HostingService: searchOrders failed', ['error' => $e->getMessage()]); + + return ['success' => false, 'message' => 'Could not fetch hosting orders.']; + } + } + + /** + * Get details of a specific hosting order by order ID. + * + * @return array{success: bool, order?: array, message?: string} + */ + public function getOrderDetails(string $orderId): array + { + try { + $response = Http::timeout(15)->get($this->endpoint('details'), [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $orderId, + ]); + + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + return ['success' => false, 'message' => 'Failed to fetch order details.']; + } + + return [ + 'success' => true, + 'order' => $this->normalizeOrderData($data), + ]; + } catch (\Throwable $e) { + Log::error('HostingService: getOrderDetails failed', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not fetch order details.']; + } + } + + private function configuredApiUrl(): string + { + $value = trim((string) config('mailinfra.hosting_api_url')); + + return $value !== '' ? $value : self::DEFAULT_API_URL; + } + + private function endpoint(string $action): string + { + return sprintf('%s/%s/%s.json', $this->apiUrl, $this->hostingEndpoint, $action); + } + + /** + * Order a new single-domain Linux hosting plan. + * + * @return array{success: bool, order_id?: string, message?: string} + */ + public function orderHosting(string $domainName, string $planId, string $customerId, int $months = 1): array + { + try { + $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('add'), [ + 'domain-name' => $domainName, + 'customer-id' => $customerId, + 'months' => $months, + 'plan-id' => $planId, + 'invoice-option' => 'NoInvoice', + ])); + + $data = $response->json(); + + if ($response->failed()) { + $message = $data['message'] ?? $data['error'] ?? 'Hosting order failed.'; + Log::warning('HostingService: orderHosting failed', [ + 'domain' => $domainName, + 'status' => $response->status(), + 'response' => $data, + ]); + + return ['success' => false, 'message' => $message]; + } + + Log::info('HostingService: hosting ordered', [ + 'domain' => $domainName, + 'order_id' => $data['entityid'] ?? null, + ]); + + return [ + 'success' => true, + 'order_id' => (string) ($data['entityid'] ?? ''), + ]; + } catch (\Throwable $e) { + Log::error('HostingService: orderHosting exception', [ + 'domain' => $domainName, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Hosting order failed due to a network error.']; + } + } + + /** + * Renew a hosting order. + * + * @return array{success: bool, message?: string} + */ + public function renewOrder(string $orderId, int $months = 1, string $invoiceOption = 'NoInvoice'): array + { + try { + $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('renew'), [ + 'order-id' => $orderId, + 'months' => $months, + 'invoice-option' => $invoiceOption, + ])); + + $data = $response->json(); + + if ($response->failed()) { + return ['success' => false, 'message' => $data['message'] ?? 'Renewal failed.']; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('HostingService: renewOrder failed', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Renewal failed due to a network error.']; + } + } + + /** + * @return array{success: bool, message?: string} + */ + public function changePanelPassword(string $orderId, string $password): array + { + try { + $response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('change-password'), [ + 'order-id' => $orderId, + 'new-passwd' => $password, + ])); + + $data = $response->json(); + + if ($response->failed()) { + return ['success' => false, 'message' => $data['message'] ?? $data['error'] ?? 'Could not update the hosting panel password.']; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('HostingService: changePanelPassword failed', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not update the hosting panel password.']; + } + } + + /** + * Delete (cancel) a hosting order. + * + * @return array{success: bool, message?: string} + */ + public function deleteOrder(string $orderId): array + { + try { + $response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('delete'), [ + 'order-id' => $orderId, + ])); + + $data = $response->json(); + + if ($response->failed()) { + return ['success' => false, 'message' => $data['message'] ?? 'Cancellation failed.']; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('HostingService: deleteOrder failed', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Cancellation failed due to a network error.']; + } + } + + /** + * Sync a hosting order from ResellerClub into the local database. + */ + public function syncOrderToLocal(array $rcOrder, int $userId, ?int $domainId = null): HostingOrder + { + $raw = is_array($rcOrder['raw'] ?? null) ? (array) $rcOrder['raw'] : []; + + return HostingOrder::query()->updateOrCreate( + ['rc_order_id' => (string) ($rcOrder['orderid'] ?? $rcOrder['order_id'] ?? $rcOrder['entityid'] ?? Arr::get($raw, 'orders.orderid') ?? Arr::get($raw, 'entity.entityid') ?? $raw['orderid'] ?? $raw['entityid'] ?? '')], + [ + 'user_id' => $userId, + 'domain_id' => $domainId, + 'domain_name' => $rcOrder['domain_name'] ?? $rcOrder['domainname'] ?? Arr::get($raw, 'entity.description') ?? Arr::get($raw, 'domainname') ?? Arr::get($raw, 'domain-name') ?? '', + 'rc_customer_id' => (string) ($rcOrder['customerid'] ?? $rcOrder['customer_id'] ?? $rcOrder['customer-id'] ?? Arr::get($raw, 'entity.customerid') ?? $raw['customerid'] ?? $raw['customer-id'] ?? ''), + 'plan_name' => $rcOrder['plan_name'] ?? $rcOrder['planname'] ?? $rcOrder['productcategory'] ?? Arr::get($raw, 'entitytype.entitytypename') ?? Arr::get($raw, 'entitytype.entitytypekey') ?? $raw['planname'] ?? $raw['productcategory'] ?? null, + 'hosting_type' => HostingOrder::TYPE_SINGLE_DOMAIN, + 'status' => $this->mapRcStatus($rcOrder['currentstatus'] ?? $rcOrder['orderstatus'] ?? $rcOrder['status'] ?? Arr::get($raw, 'entity.currentstatus') ?? Arr::get($raw, 'orders.orderstatus') ?? 'pending'), + 'ip_address' => $rcOrder['ipaddress'] ?? $rcOrder['ip'] ?? $rcOrder['ip_address'] ?? Arr::get($raw, 'server.ipaddress') ?? Arr::get($raw, 'server.ip') ?? $raw['ipaddress'] ?? $raw['ip'] ?? null, + 'cpanel_url' => $rcOrder['cpanelurl'] ?? $rcOrder['cpanel_url'] ?? $rcOrder['tempurl'] ?? Arr::get($raw, 'server.cpanelurl') ?? Arr::get($raw, 'server.controlpanelurl') ?? Arr::get($raw, 'server.tempurl') ?? $raw['cpanelurl'] ?? $raw['tempurl'] ?? null, + 'cpanel_username' => $rcOrder['cpanelusername'] ?? $rcOrder['cpanel_username'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? null, + 'bandwidth_mb' => $this->unsignedLimitOrNull($rcOrder['bandwidth'] ?? Arr::get($raw, 'plan.bandwidth') ?? $raw['bandwidth'] ?? null), + 'disk_space_mb' => $this->unsignedLimitOrNull($rcOrder['diskspace'] ?? Arr::get($raw, 'plan.diskspace') ?? $raw['diskspace'] ?? null), + 'expires_at' => $this->hostingTimestamp($rcOrder['endtime'] ?? $rcOrder['end_time'] ?? Arr::get($raw, 'orders.endtime') ?? $raw['endtime'] ?? null), + 'provisioned_at' => $this->hostingTimestamp($rcOrder['creationtime'] ?? $rcOrder['creation_time'] ?? Arr::get($raw, 'orders.creationtime') ?? $raw['creationtime'] ?? null), + 'meta' => $raw !== [] ? $raw : $rcOrder, + ] + ); + } + + /** + * Sync all orders from ResellerClub for a user. + * If user has a linked RC account, only syncs that customer's orders. + * Otherwise syncs all orders under the reseller account. + * + * @return int Number of orders synced. + */ + public function syncAllOrders(int $userId, ?string $rcCustomerId = null): int + { + if ($rcCustomerId) { + return $this->syncCustomerOrders($rcCustomerId, $userId); + } + + $page = 1; + $synced = 0; + + do { + $result = $this->searchOrders($page, 50); + + if (! ($result['success'] ?? false) || empty($result['orders'])) { + break; + } + + foreach ($result['orders'] as $order) { + $this->syncOrderToLocal($order, $userId); + $synced++; + } + + $page++; + } while ($synced < ($result['total'] ?? 0)); + + return $synced; + } + + private function normalizeOrderData(array $data): array + { + return [ + 'order_id' => (string) ($data['orderid'] ?? $data['entityid'] ?? $data['order-id'] ?? $data['entity-id'] ?? Arr::get($data, 'orders.orderid') ?? Arr::get($data, 'entity.entityid') ?? ''), + 'domain_name' => $data['domainname'] ?? $data['domain-name'] ?? $data['domain_name'] ?? $data['domain'] ?? $data['hostname'] ?? $data['description'] ?? Arr::get($data, 'entity.description') ?? '', + 'customer_id' => (string) ($data['customerid'] ?? $data['customer-id'] ?? $data['customer_id'] ?? Arr::get($data, 'entity.customerid') ?? ''), + 'plan_name' => $data['planname'] ?? $data['plan_name'] ?? $data['productcategory'] ?? $data['productkey'] ?? $data['description'] ?? Arr::get($data, 'entitytype.entitytypename') ?? Arr::get($data, 'entitytype.entitytypekey') ?? '', + 'status' => $data['currentstatus'] ?? $data['orderstatus'] ?? $data['status'] ?? Arr::get($data, 'entity.currentstatus') ?? Arr::get($data, 'orders.orderstatus') ?? 'Unknown', + 'ip_address' => $data['ipaddress'] ?? $data['ip_address'] ?? $data['ip'] ?? Arr::get($data, 'server.ipaddress') ?? Arr::get($data, 'server.ip') ?? null, + 'cpanel_url' => $data['cpanelurl'] ?? $data['cpanel_url'] ?? $data['controlpanelurl'] ?? $data['tempurl'] ?? Arr::get($data, 'server.cpanelurl') ?? Arr::get($data, 'server.controlpanelurl') ?? Arr::get($data, 'server.tempurl') ?? null, + 'cpanel_username' => $data['cpanelusername'] ?? $data['cpanel_username'] ?? $data['username'] ?? Arr::get($data, 'server.cpanelusername') ?? Arr::get($data, 'server.username') ?? null, + 'bandwidth' => $data['bandwidth'] ?? $data['bandwidth_mb'] ?? Arr::get($data, 'plan.bandwidth') ?? null, + 'disk_space' => $data['diskspace'] ?? $data['disk_space_mb'] ?? Arr::get($data, 'plan.diskspace') ?? null, + 'creation_time' => $data['creationtime'] ?? $data['creation_time'] ?? Arr::get($data, 'orders.creationtime') ?? null, + 'end_time' => $data['endtime'] ?? $data['end_time'] ?? $data['expirytime'] ?? Arr::get($data, 'orders.endtime') ?? null, + 'raw' => $data, + ]; + } + + private function mapRcStatus(string $rcStatus): string + { + return match (strtolower($rcStatus)) { + 'active', 'active (paid)' => HostingOrder::STATUS_ACTIVE, + 'suspended' => HostingOrder::STATUS_SUSPENDED, + 'deleted', 'cancelled' => HostingOrder::STATUS_CANCELLED, + 'expired' => HostingOrder::STATUS_EXPIRED, + 'pending', 'inactive' => HostingOrder::STATUS_PENDING, + default => HostingOrder::STATUS_PENDING, + }; + } + + private function syncErrorMessage(\Throwable $e, string $fallback): string + { + $message = $e->getMessage(); + + if (str_contains($message, 'Could not resolve host')) { + return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.'; + } + + return $fallback; + } + + /** + * @param mixed $data + */ + private function responseMessage(Response $response, mixed $data, string $fallback): string + { + if ($this->looksLikeHtmlError($response)) { + return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.'; + } + + if (is_array($data)) { + foreach (['message', 'error', 'description', 'msg'] as $key) { + $value = $this->sanitizeErrorText((string) ($data[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + } + + $rawBody = $this->sanitizeErrorText((string) $response->body()); + + return $rawBody !== '' ? $rawBody : $fallback; + } + + private function sanitizeErrorText(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if ($this->looksLikeHtmlDocument($value)) { + return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.'; + } + + return trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); + } + + private function looksLikeHtmlError(Response $response): bool + { + $contentType = strtolower((string) $response->header('Content-Type')); + $body = trim((string) $response->body()); + + return str_contains($contentType, 'text/html') || $this->looksLikeHtmlDocument($body); + } + + private function looksLikeHtmlDocument(string $value): bool + { + $normalized = strtolower(ltrim($value)); + + return str_starts_with($normalized, '') + || str_contains($normalized, 'cloudflare'); + } + + private function hostingTimestamp(mixed $value): ?\Carbon\Carbon + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return \Carbon\Carbon::createFromTimestamp((int) $value); + } + + try { + return \Carbon\Carbon::parse((string) $value); + } catch (\Throwable) { + return null; + } + } + + private function unsignedLimitOrNull(mixed $value): ?int + { + if (! is_numeric($value)) { + return null; + } + + $normalized = (int) $value; + + return $normalized >= 0 ? $normalized : null; + } +} diff --git a/app/Services/Hosting/ServerAgentBootstrapService.php b/app/Services/Hosting/ServerAgentBootstrapService.php new file mode 100644 index 0000000..623d490 --- /dev/null +++ b/app/Services/Hosting/ServerAgentBootstrapService.php @@ -0,0 +1,202 @@ +loadMissing('serverAgent'); + + $meta = (array) ($order->meta ?? []); + $agentMeta = (array) ($meta['server_agent'] ?? []); + $bootstrapToken = $this->decryptBootstrapToken((string) ($agentMeta['bootstrap_token_encrypted'] ?? '')); + + if ($bootstrapToken === null) { + $bootstrapToken = Str::random(64); + } + + $agent = $order->serverAgent ?: new ServerAgent([ + 'customer_hosting_order_id' => $order->id, + 'uuid' => (string) Str::uuid(), + 'status' => ServerAgent::STATUS_PENDING_REGISTRATION, + ]); + + $agent->fill([ + 'bootstrap_token_hash' => hash('sha256', $bootstrapToken), + ]); + $agent->save(); + + $agentMeta = array_merge($agentMeta, [ + 'agent_id' => $agent->id, + 'agent_uuid' => $agent->uuid, + 'bootstrap_token_encrypted' => encrypt($bootstrapToken), + 'registration_url' => route('api.server-agents.register'), + 'heartbeat_url' => route('api.server-agents.heartbeat'), + 'task_progress_url_template' => url('/api/server-agents/tasks/{task}/progress'), + 'task_complete_url_template' => url('/api/server-agents/tasks/{task}/complete'), + 'task_fail_url_template' => url('/api/server-agents/tasks/{task}/fail'), + ]); + + $meta['server_agent'] = $agentMeta; + $meta['server_panel_runtime'] = $this->withRuntimeAgentState( + (array) ($meta['server_panel_runtime'] ?? []), + $agent + ); + + $order->update(['meta' => $meta]); + + return [ + 'agent' => $agent->fresh(), + 'bootstrap_token' => $bootstrapToken, + 'registration_url' => $agentMeta['registration_url'], + 'heartbeat_url' => $agentMeta['heartbeat_url'], + ]; + } + + /** + * @param array $attributes + * @return array{agent: ServerAgent, access_token: string} + */ + public function register(CustomerHostingOrder $order, string $bootstrapToken, array $attributes): array + { + $bootstrap = $this->ensureBootstrap($order); + $agent = $bootstrap['agent']; + + if (! hash_equals((string) $agent->bootstrap_token_hash, hash('sha256', $bootstrapToken))) { + throw ValidationException::withMessages([ + 'bootstrap_token' => 'The bootstrap token is invalid.', + ]); + } + + $accessToken = Str::random(80); + + $agent->fill([ + 'status' => ServerAgent::STATUS_ONLINE, + 'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname), + 'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version), + 'platform' => (string) ($attributes['platform'] ?? $agent->platform), + 'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? []))), + 'metadata' => (array) ($attributes['metadata'] ?? $agent->metadata ?? []), + 'access_token_hash' => hash('sha256', $accessToken), + 'access_token_issued_at' => now(), + 'registered_at' => $agent->registered_at ?? now(), + 'last_heartbeat_at' => now(), + 'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip), + ]); + $agent->save(); + + $this->syncOrderRuntime($order->fresh(), $agent->fresh()); + + return [ + 'agent' => $agent->fresh(), + 'access_token' => $accessToken, + ]; + } + + /** + * @param array $attributes + */ + public function recordHeartbeat(ServerAgent $agent, array $attributes = []): ServerAgent + { + $agent->fill([ + 'status' => ServerAgent::STATUS_ONLINE, + 'hostname' => (string) ($attributes['hostname'] ?? $agent->hostname), + 'agent_version' => (string) ($attributes['agent_version'] ?? $agent->agent_version), + 'platform' => (string) ($attributes['platform'] ?? $agent->platform), + 'capabilities' => array_values(array_unique((array) ($attributes['capabilities'] ?? $agent->capabilities ?? []))), + 'metadata' => array_replace((array) ($agent->metadata ?? []), (array) ($attributes['metadata'] ?? [])), + 'last_heartbeat_at' => now(), + 'last_seen_ip' => (string) ($attributes['last_seen_ip'] ?? $agent->last_seen_ip), + ]); + $agent->save(); + + if ($agent->relationLoaded('order')) { + $this->syncOrderRuntime($agent->order, $agent); + } else { + $this->syncOrderRuntime($agent->order()->firstOrFail(), $agent); + } + + return $agent->fresh(); + } + + public function syncOrderRuntime(CustomerHostingOrder $order, ServerAgent $agent): void + { + $meta = (array) ($order->meta ?? []); + $meta['server_agent'] = array_merge((array) ($meta['server_agent'] ?? []), [ + 'agent_id' => $agent->id, + 'agent_uuid' => $agent->uuid, + 'status' => $agent->status, + 'hostname' => $agent->hostname, + 'agent_version' => $agent->agent_version, + 'platform' => $agent->platform, + 'capabilities' => (array) ($agent->capabilities ?? []), + 'registered_at' => optional($agent->registered_at)->toIso8601String(), + 'last_heartbeat_at' => optional($agent->last_heartbeat_at)->toIso8601String(), + ]); + $meta['server_panel_runtime'] = $this->withRuntimeAgentState( + (array) ($meta['server_panel_runtime'] ?? []), + $agent + ); + + $order->update(['meta' => $meta]); + } + + /** + * @param array $runtime + * @return array + */ + private function withRuntimeAgentState(array $runtime, ServerAgent $agent): array + { + $current = array_values(array_unique(array_merge( + (array) ($runtime['current_capabilities'] ?? []), + ['Agent registration', 'Async task dispatch'] + ))); + + if ($agent->status === ServerAgent::STATUS_ONLINE) { + $current = array_values(array_unique(array_merge($current, [ + 'Files', + 'Databases', + 'Domains', + 'SSL', + 'PHP', + 'Cron', + 'Logs', + 'Site management', + ]))); + } + + $runtime['hosting_panel_ready'] = $agent->registered_at !== null; + $runtime['future_hosting_panel_status'] = $agent->registered_at !== null ? 'agent_registered' : ($runtime['future_hosting_panel_status'] ?? 'planned'); + $runtime['agent_status'] = $agent->status; + $runtime['current_capabilities'] = $current; + $runtime['secondary_message'] = $agent->registered_at !== null + ? 'The server agent is registered. Managed tasks for files, databases, domains, SSL, PHP, cron, logs, and site actions are now queued through Ladill.' + : ($runtime['secondary_message'] ?? null); + + return $runtime; + } + + private function decryptBootstrapToken(string $encryptedToken): ?string + { + if ($encryptedToken === '') { + return null; + } + + try { + $token = decrypt($encryptedToken); + } catch (\Throwable) { + return null; + } + + return is_string($token) && $token !== '' ? $token : null; + } +} diff --git a/app/Services/Hosting/ServerAgentInstallerService.php b/app/Services/Hosting/ServerAgentInstallerService.php new file mode 100644 index 0000000..7f38ca7 --- /dev/null +++ b/app/Services/Hosting/ServerAgentInstallerService.php @@ -0,0 +1,229 @@ +buildCloudInit( + $order, + $bootstrap, + (string) ($provisioning['user_data'] ?? '') + ); + + return $provisioning; + } + + public function buildCloudInit(CustomerHostingOrder $order, array $bootstrap, string $existingCloudInit = ''): string + { + $parsed = $this->parseGeneratedCloudInit($existingCloudInit); + $release = $this->releaseService->ensureRelease(); + $releaseUrl = $this->releaseService->temporaryDownloadUrl($release['version']); + $envFile = implode("\n", [ + 'ORDER_ID='.$order->id, + 'BOOTSTRAP_TOKEN='.$bootstrap['bootstrap_token'], + 'REGISTRATION_URL='.$bootstrap['registration_url'], + 'HEARTBEAT_URL='.$bootstrap['heartbeat_url'], + 'AGENT_RELEASE_VERSION='.$release['version'], + '', + ]); + + $packages = array_values(array_unique(array_merge($parsed['packages'], [ + 'curl', + 'wget', + 'jq', + 'ca-certificates', + 'git', + 'unzip', + 'zip', + ]))); + + $runCommands = array_merge([ + $this->managedStackInstallCommand($releaseUrl, $release['checksum_sha256']), + 'bash -lc \'mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release\'', + 'bash -lc \'cd /tmp/ladill-server-agent-release && bash install.sh\'', + 'bash -lc \'chmod 0600 /etc/ladill/server-agent.env\'', + $this->managedStackEnableServicesCommand(), + ], $parsed['run_commands']); + + return $this->contaboProvider->generateCloudInit([ + 'packages' => $packages, + 'write_files' => [ + [ + 'path' => '/etc/ladill/server-agent.env', + 'permissions' => '0600', + 'owner' => 'root:root', + 'encoding' => 'b64', + 'content' => base64_encode($envFile), + ], + ], + 'run_commands' => $runCommands, + ]); + } + + /** + * @return array{packages: array, run_commands: array} + */ + private function parseGeneratedCloudInit(string $cloudInit): array + { + $packages = []; + $runCommands = []; + $section = null; + + foreach (preg_split("/\r\n|\n|\r/", $cloudInit) ?: [] as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || $trimmed === '#cloud-config') { + continue; + } + + if (! str_starts_with($line, ' ')) { + $section = match ($trimmed) { + 'packages:' => 'packages', + 'runcmd:' => 'runcmd', + default => null, + }; + continue; + } + + if ($section === 'packages' && str_starts_with($trimmed, '- ')) { + $packages[] = trim(substr($trimmed, 2)); + } + + if ($section === 'runcmd' && str_starts_with($trimmed, '- ')) { + $runCommands[] = trim(substr($trimmed, 2)); + } + } + + return [ + 'packages' => array_values(array_filter($packages)), + 'run_commands' => array_values(array_filter($runCommands)), + ]; + } + + private function managedStackInstallCommand(string $releaseUrl, string $checksum): string + { + $script = str_replace( + ['__RELEASE_URL__', '__CHECKSUM__'], + [$releaseUrl, $checksum], + <<<'BASH' +set -e + +detect_pm() { + if command -v apt-get >/dev/null 2>&1; then + echo apt-get + return 0 + fi + + if command -v dnf >/dev/null 2>&1; then + echo dnf + return 0 + fi + + if command -v yum >/dev/null 2>&1; then + echo yum + return 0 + fi + + if command -v zypper >/dev/null 2>&1; then + echo zypper + return 0 + fi + + return 1 +} + +install_composer_if_missing() { + if command -v composer >/dev/null 2>&1; then + return 0 + fi + + curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php + php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer + rm -f /tmp/composer-setup.php +} + +pm="$(detect_pm || true)" +if [ -z "${pm}" ]; then + echo "Ladill managed stack currently supports apt, dnf, yum, or zypper based Linux distributions only." >&2 + exit 1 +fi + +case "${pm}" in + apt-get) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y nginx mariadb-server php-fpm php-cli php-mysql php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath certbot jq curl wget ca-certificates cron composer git unzip zip bubblewrap + ;; + dnf) + dnf install -y epel-release || true + dnf install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap + dnf install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true + ;; + yum) + yum install -y epel-release || true + yum install -y nginx mariadb-server php-fpm php-cli php-mysqlnd certbot jq curl wget ca-certificates cronie git unzip zip bubblewrap + yum install -y php-curl php-xml php-zip php-mbstring php-gd php-intl php-bcmath || true + ;; + zypper) + zypper --non-interactive refresh + zypper --non-interactive install -y nginx mariadb php8 php8-fpm php8-mysql certbot jq curl wget ca-certificates cron git unzip zip bubblewrap + zypper --non-interactive install -y php8-curl php8-xmlreader php8-xmlwriter php8-zip php8-mbstring php8-gd php8-intl php8-bcmath || true + ;; +esac + +install_composer_if_missing +mkdir -p /etc/ladill /var/lib/ladill-server-agent /var/www /tmp/ladill-server-agent-release +curl -fsSL "__RELEASE_URL__" -o /tmp/ladill-server-agent-release/agent.tar.gz +echo "__CHECKSUM__ /tmp/ladill-server-agent-release/agent.tar.gz" | sha256sum -c - +tar -xzf /tmp/ladill-server-agent-release/agent.tar.gz -C /tmp/ladill-server-agent-release +BASH + ); + + return 'bash -lc '.escapeshellarg($script); + } + + private function managedStackEnableServicesCommand(): string + { + return 'bash -lc '.escapeshellarg(<<<'BASH' +set -e + +service_exists() { + systemctl list-unit-files "$1.service" --no-legend 2>/dev/null | grep -q "^$1\.service" +} + +enable_if_present() { + for service in "$@"; do + if [ -n "$service" ] && service_exists "$service"; then + systemctl enable --now "$service" >/dev/null 2>&1 || true + return 0 + fi + done + + return 1 +} + +chmod 0600 /etc/ladill/server-agent.env +systemctl daemon-reload +enable_if_present nginx +enable_if_present mariadb mysql +enable_if_present cron crond +enable_if_present php-fpm php8-fpm php8.4-fpm php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm +enable_if_present ladill-server-agent +BASH + ); + } +} diff --git a/app/Services/Hosting/ServerAgentReleaseService.php b/app/Services/Hosting/ServerAgentReleaseService.php new file mode 100644 index 0000000..01d3d2a --- /dev/null +++ b/app/Services/Hosting/ServerAgentReleaseService.php @@ -0,0 +1,117 @@ +currentVersion(); + $releaseDirectory = storage_path('app/server-agent/releases/'.$version); + $packageDirectory = $releaseDirectory.'/package'; + $archiveFilename = "ladill-server-agent-{$version}.tar.gz"; + $archivePath = $releaseDirectory.'/'.$archiveFilename; + $tarPath = $releaseDirectory."/ladill-server-agent-{$version}.tar"; + + File::ensureDirectoryExists($packageDirectory); + File::ensureDirectoryExists($releaseDirectory); + + $renderedFiles = [ + 'install.sh' => $this->renderInstallScript(), + 'ladill-server-agent' => $this->renderAgentScript($version), + 'ladill-server-agent.service' => $this->renderTemplate(resource_path('server-agent/ladill-server-agent.service.tpl')), + 'manifest.json' => json_encode([ + 'name' => 'ladill-server-agent', + 'version' => $version, + 'built_at' => now()->toIso8601String(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n", + ]; + + foreach ($renderedFiles as $filename => $contents) { + File::put($packageDirectory.'/'.$filename, $contents); + } + + if (! File::exists($archivePath)) { + if (File::exists($tarPath)) { + File::delete($tarPath); + } + + try { + $phar = new PharData($tarPath); + foreach (array_keys($renderedFiles) as $filename) { + $phar->addFile($packageDirectory.'/'.$filename, $filename); + } + $phar->compress(Phar::GZ); + unset($phar); + } catch (\Throwable $e) { + throw new RuntimeException('Unable to build the server agent release archive.', previous: $e); + } finally { + if (File::exists($tarPath)) { + File::delete($tarPath); + } + } + } + + if (! File::exists($archivePath)) { + throw new RuntimeException('The server agent release archive was not created.'); + } + + return [ + 'version' => $version, + 'archive_path' => $archivePath, + 'checksum_sha256' => hash_file('sha256', $archivePath), + 'filename' => $archiveFilename, + ]; + } + + public function temporaryDownloadUrl(?string $version = null, ?\DateTimeInterface $expiresAt = null): string + { + $version ??= $this->currentVersion(); + $expiresAt ??= now()->addMinutes((int) config('hosting.server_agent.signed_release_ttl_minutes', 10080)); + + return URL::temporarySignedRoute('server-agent.releases.download', $expiresAt, [ + 'version' => $version, + ]); + } + + private function renderAgentScript(string $version): string + { + return str_replace( + ['{{AGENT_VERSION}}', '{{HEARTBEAT_INTERVAL_SECONDS}}'], + [ + $version, + (string) config('hosting.server_agent.heartbeat_interval_seconds', 15), + ], + $this->renderTemplate(resource_path('server-agent/ladill-server-agent.sh.tpl')) + ); + } + + private function renderInstallScript(): string + { + return $this->renderTemplate(resource_path('server-agent/install.sh.tpl')); + } + + private function renderTemplate(string $path): string + { + $contents = @file_get_contents($path); + if ($contents === false) { + throw new RuntimeException("Unable to load server agent release template: {$path}"); + } + + return $contents; + } +} diff --git a/app/Services/Hosting/ServerOrderService.php b/app/Services/Hosting/ServerOrderService.php new file mode 100644 index 0000000..f9cf77e --- /dev/null +++ b/app/Services/Hosting/ServerOrderService.php @@ -0,0 +1,942 @@ + ['IPFS', 'ipfs'], + 'flux_node' => ['Flux', 'flux', 'FluxOS'], + 'horizon_node' => ['Horizen', 'horizon', 'Zen'], + 'ethereum_node' => ['Ethereum', 'eth-docker'], + 'bitcoin_node' => ['Bitcoin'], + ]; + private ?array $availableImages = null; + + public function __construct( + private HostingPricingService $pricing, + private ContaboProvider $contaboProvider, + private ServerPanelCapabilityService $panelCapabilities, + ) {} + + public function passwordPattern(): string + { + return self::PASSWORD_PATTERN; + } + + /** + * @return array + */ + public function frontendPayload(HostingProduct $product): array + { + $defaults = $this->defaultSelections($product); + + return [ + 'id' => $product->id, + 'name' => $product->name, + 'currency' => $product->display_currency, + 'prices' => $this->baseCyclePrices($product), + 'setup_fees' => $this->setupFeesByCycle($product), + 'catalog' => $this->catalogForProduct($product), + 'defaults' => $defaults, + 'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $defaults, $this->catalogForProduct($product)), + ]; + } + + /** + * @return array + */ + public function catalogForProduct(HostingProduct $product): array + { + $images = $this->imageOptions($product); + $defaultImage = $this->defaultImageOption($images)['value'] ?? null; + $defaultImageMeta = $defaultImage ? $this->findOption($images, $defaultImage) : null; + + return [ + 'images' => $images, + 'regions' => $this->optionCollection($product, (array) config('hosting.server_order.regions', [])), + 'licenses' => $this->optionCollection($product, (array) config('hosting.server_order.licenses', [])), + 'applications' => $this->applicationOptions($product), + 'additional_ip' => $this->optionCollection($product, (array) config('hosting.server_order.additional_ip', [])), + 'private_networking' => $this->optionCollection($product, (array) config('hosting.server_order.private_networking', [])), + 'storage_types' => $this->optionCollection($product, (array) config('hosting.server_order.storage_types', [])), + 'object_storage' => $this->optionCollection($product, (array) config('hosting.server_order.object_storage', [])), + 'linux_default_users' => $this->defaultUserOptions($product, 'linux'), + 'windows_default_users' => $this->defaultUserOptions($product, 'windows'), + 'default_image_os_family' => $defaultImageMeta['os_family'] ?? 'linux', + ]; + } + + /** + * @param array $input + * @return array{selection: array, quote: array} + */ + public function selectionAndQuote(HostingProduct $product, array $input): array + { + $catalog = $this->catalogForProduct($product); + $defaults = $this->defaultSelections($product); + $billingCycle = (string) ($input['billing_cycle'] ?? CustomerHostingOrder::CYCLE_MONTHLY); + + $selection = [ + 'image' => $this->validatedOptionValue($catalog['images'], (string) ($input['image'] ?? $defaults['image'])), + 'region' => $this->validatedOptionValue($catalog['regions'], (string) ($input['region'] ?? $defaults['region'])), + 'license' => $this->validatedOptionValue($catalog['licenses'], (string) ($input['license'] ?? $defaults['license'])), + 'application' => $this->validatedOptionValue($catalog['applications'], (string) ($input['application'] ?? $defaults['application'])), + 'additional_ip' => $this->validatedOptionValue($catalog['additional_ip'], (string) ($input['additional_ip'] ?? $defaults['additional_ip'])), + 'private_networking' => $this->validatedOptionValue($catalog['private_networking'], (string) ($input['private_networking'] ?? $defaults['private_networking'])), + 'storage_type' => $this->validatedOptionValue($catalog['storage_types'], (string) ($input['storage_type'] ?? $defaults['storage_type'])), + 'object_storage' => $this->validatedOptionValue($catalog['object_storage'], (string) ($input['object_storage'] ?? $defaults['object_storage'])), + ]; + + $image = $this->findOption($catalog['images'], $selection['image']); + $defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux')); + $selection['default_user'] = $this->validatedOptionValue( + $defaultUsers, + (string) ($input['default_user'] ?? $image['default_user'] ?? array_key_first($defaultUsers)) + ); + $this->assertCompatibleSelections($selection, $catalog); + + $quote = $this->quote($product, $selection, $billingCycle, $catalog); + + return [ + 'selection' => $selection, + 'quote' => $quote, + 'selection_summary' => $this->selectionSummary($product, $selection, $catalog), + 'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog), + ]; + } + + /** + * @param array $selection + * @param array|null $catalog + * @return array + */ + public function quote(HostingProduct $product, array $selection, string $billingCycle, ?array $catalog = null): array + { + $catalog ??= $this->catalogForProduct($product); + $basePrices = $this->baseCyclePrices($product); + $baseTotal = (float) ($basePrices[$billingCycle] ?? 0); + + $lines = [[ + 'code' => 'base_plan', + 'label' => $product->name, + 'amount' => round($baseTotal, 2), + 'automated' => true, + ]]; + + $manualFollowUp = []; + $setupFee = $this->setupFeeForCycle($product, $billingCycle); + + if ($setupFee > 0) { + $lines[] = [ + 'code' => 'setup_fee', + 'label' => $this->setupFeeLabel($product, $billingCycle), + 'amount' => round($setupFee, 2), + 'automated' => true, + 'one_time' => true, + ]; + } + + foreach ([ + 'image' => 'images', + 'region' => 'regions', + 'license' => 'licenses', + 'application' => 'applications', + 'additional_ip' => 'additional_ip', + 'private_networking' => 'private_networking', + 'storage_type' => 'storage_types', + 'object_storage' => 'object_storage', + ] as $selectionKey => $catalogKey) { + $value = (string) ($selection[$selectionKey] ?? ''); + if ($value === '') { + continue; + } + + $option = $this->findOption($catalog[$catalogKey], $value); + if (! $option) { + continue; + } + + $amount = (float) ($option['prices'][$billingCycle] ?? 0); + if ($amount <= 0) { + continue; + } + + $lines[] = [ + 'code' => $selectionKey, + 'label' => (string) ($option['label'] ?? $value), + 'amount' => round($amount, 2), + 'automated' => (bool) ($option['automated'] ?? false), + ]; + + if (! ($option['automated'] ?? false)) { + $manualFollowUp[] = (string) ($option['label'] ?? $value); + } + } + + $total = round(collect($lines)->sum('amount'), 2); + + return [ + 'currency' => $product->display_currency, + 'billing_cycle' => $billingCycle, + 'months' => $this->cycleMonths($billingCycle), + 'base_total' => round($baseTotal, 2), + 'line_items' => $lines, + 'total' => $total, + 'manual_follow_up' => array_values(array_unique($manualFollowUp)), + ]; + } + + /** + * @param array $selection + * @return array + */ + public function provisioningConfig(HostingProduct $product, array $selection, string $serverName, string $billingCycle): array + { + $catalog = $this->catalogForProduct($product); + $image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? '')); + $license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? '')); + $application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? '')); + $region = $this->findOption($catalog['regions'], (string) ($selection['region'] ?? '')); + $additionalIp = $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? '')); + $privateNetworking = $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? '')); + $storageType = $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? '')); + $objectStorage = $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? '')); + + $addOns = []; + foreach ([$image, $additionalIp, $privateNetworking, $storageType] as $option) { + $addOnKey = (string) ($option['add_on'] ?? ''); + if ($addOnKey !== '') { + $addOns[$addOnKey] = new \stdClass(); + } + } + + $manualFollowUp = []; + foreach ([$license, $application, $storageType, $objectStorage] as $option) { + if ($option && ! ($option['automated'] ?? false)) { + $manualFollowUp[] = (string) ($option['label'] ?? $option['value'] ?? 'Option'); + } + } + + $applicationId = $application['application_id'] ?? null; + $userData = $this->cloudInitForOption((array) $application, (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root'))); + + return [ + 'product_id' => $product->contabo_product_id, + 'image_id' => ! ($image['is_install_later'] ?? false) ? ($image['image_id'] ?? $selection['image'] ?? config('hosting.vps.default_image')) : null, + 'region' => (string) ($region['value'] ?? $selection['region'] ?? $product->contabo_region ?? config('hosting.vps.default_region', 'EU')), + 'display_name' => $serverName, + 'default_user' => (string) ($selection['default_user'] ?? ($image['default_user'] ?? 'root')), + 'license' => $license['license'] ?? null, + 'panel' => $license['panel'] ?? null, + 'application_id' => is_string($applicationId) && $applicationId !== '' ? $applicationId : null, + 'user_data' => $userData, + 'period' => $this->cycleMonths($billingCycle), + 'add_ons' => $addOns, + 'manual_follow_up' => array_values(array_unique($manualFollowUp)), + 'panel_snapshot' => $this->panelCapabilities->describeSelection($product, $selection, $catalog), + ]; + } + + /** + * @return array + */ + public function defaultSelections(HostingProduct $product): array + { + $catalog = $this->catalogForProduct($product); + $defaultImage = $catalog['images'][0]['value'] ?? null; + $defaultImageMeta = $defaultImage ? $this->findOption($catalog['images'], $defaultImage) : null; + $defaultUserOptions = $this->defaultUserOptions($product, (string) ($defaultImageMeta['os_family'] ?? 'linux')); + + return [ + 'image' => $defaultImage, + 'region' => (string) ($catalog['regions'][0]['value'] ?? 'EU'), + 'license' => 'none', + 'application' => 'none', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + 'default_user' => (string) ($defaultUserOptions[0]['value'] ?? 'root'), + ]; + } + + /** + * @param array $selection + * @param array|null $catalog + * @return array> + */ + public function selectionSummary(HostingProduct $product, array $selection, ?array $catalog = null): array + { + $catalog ??= $this->catalogForProduct($product); + $image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? '')); + $defaultUsers = $this->defaultUserOptions($product, (string) ($image['os_family'] ?? 'linux')); + + return array_values(array_filter([ + $this->selectionSummaryItem('image', 'Image', $this->findOption($catalog['images'], (string) ($selection['image'] ?? ''))), + $this->selectionSummaryItem('region', 'Region', $this->findOption($catalog['regions'], (string) ($selection['region'] ?? ''))), + $this->selectionSummaryItem('license', 'License', $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? ''))), + $this->selectionSummaryItem('application', 'Preinstalled App', $this->findOption($catalog['applications'], (string) ($selection['application'] ?? ''))), + $this->selectionSummaryItem('default_user', 'Default User', $this->findOption($defaultUsers, (string) ($selection['default_user'] ?? '')), true), + $this->selectionSummaryItem('additional_ip', 'IP Address', $this->findOption($catalog['additional_ip'], (string) ($selection['additional_ip'] ?? ''))), + $this->selectionSummaryItem('private_networking', 'Private Networking', $this->findOption($catalog['private_networking'], (string) ($selection['private_networking'] ?? ''))), + $this->selectionSummaryItem('storage_type', 'Storage Type', $this->findOption($catalog['storage_types'], (string) ($selection['storage_type'] ?? ''))), + $this->selectionSummaryItem('object_storage', 'Object Storage', $this->findOption($catalog['object_storage'], (string) ($selection['object_storage'] ?? ''))), + ])); + } + + /** + * @return array + */ + public function baseCyclePrices(HostingProduct $product): array + { + $cycles = [ + CustomerHostingOrder::CYCLE_MONTHLY, + CustomerHostingOrder::CYCLE_QUARTERLY, + CustomerHostingOrder::CYCLE_SEMIANNUAL, + CustomerHostingOrder::CYCLE_YEARLY, + ]; + + $prices = []; + foreach ($cycles as $cycle) { + $price = $product->getPriceForCycle($cycle); + if ($price !== null) { + $prices[$cycle] = (float) $price; + } + } + + return $prices; + } + + /** + * @return array + */ + public function setupFeesByCycle(HostingProduct $product): array + { + $fees = []; + foreach (array_keys($this->baseCyclePrices($product)) as $cycle) { + $fees[$cycle] = $this->setupFeeForCycle($product, $cycle); + } + + return $fees; + } + + public function setupFeeForCycle(HostingProduct $product, string $billingCycle): float + { + if (! $product->requiresContabo()) { + return 0.0; + } + + $productId = trim((string) $product->contabo_product_id); + + if ($productId === '') { + return 0.0; + } + + foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) { + if (! in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true)) { + continue; + } + + if (! in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true)) { + continue; + } + + if (isset($rule['fixed_eur_by_cycle'])) { + $eurAmount = (float) ($rule['fixed_eur_by_cycle'][$billingCycle] ?? 0); + if ($eurAmount <= 0) { + return 0.0; + } + $eurToGhs = app(\App\Services\ExchangeRateService::class)->getEurToGhs(); + $margin = $this->pricing->getMargin($product->category); + $ghs = $eurAmount * $eurToGhs * (1 + ($margin / 100)); + return round($ghs / 5) * 5; + } + + $monthlyPrice = (float) $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY); + $multiplier = (float) ($rule['monthly_price_multiplier'] ?? 1); + + return round($monthlyPrice * $multiplier, 2); + } + + return 0.0; + } + + private function setupFeeLabel(HostingProduct $product, string $billingCycle): string + { + $productId = trim((string) $product->contabo_product_id); + + foreach ((array) config('hosting.pricing.setup_fee_rules', []) as $rule) { + if ( + in_array($productId, array_map('strval', (array) ($rule['product_ids'] ?? [])), true) + && in_array($billingCycle, array_map('strval', (array) ($rule['billing_cycles'] ?? [])), true) + ) { + return (string) ($rule['label'] ?? 'One-time setup fee'); + } + } + + return 'One-time setup fee'; + } + + public function cycleMonths(string $billingCycle): int + { + return match ($billingCycle) { + CustomerHostingOrder::CYCLE_MONTHLY => 1, + CustomerHostingOrder::CYCLE_QUARTERLY => 3, + CustomerHostingOrder::CYCLE_SEMIANNUAL => 6, + CustomerHostingOrder::CYCLE_YEARLY => 12, + CustomerHostingOrder::CYCLE_BIENNIAL => 24, + default => 1, + }; + } + + /** + * @param array> $options + * @return array|null + */ + private function findOption(array $options, ?string $value): ?array + { + foreach ($options as $option) { + if ((string) ($option['value'] ?? '') === (string) $value) { + return $option; + } + } + + return null; + } + + /** + * @param array> $options + */ + private function validatedOptionValue(array $options, string $value): string + { + return $this->findOption($options, $value)['value'] ?? (string) ($options[0]['value'] ?? ''); + } + + /** + * @param array> $options + * @return array> + */ + private function optionCollection(HostingProduct $product, array $options): array + { + $items = []; + + foreach ($options as $value => $option) { + if (! $this->optionIsAvailable($option, (string) $value)) { + continue; + } + + $items[] = [ + 'value' => (string) $value, + 'label' => (string) ($option['label'] ?? $value), + 'description' => (string) ($option['description'] ?? ''), + 'monthly_usd' => (float) ($option['monthly_usd'] ?? 0), + 'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($option['monthly_usd'] ?? 0)), + 'license' => $option['license'] ?? null, + 'panel' => $option['panel'] ?? null, + 'application_id' => $option['application_id'] ?? null, + 'cloud_init_preset' => $option['cloud_init_preset'] ?? null, + 'add_on' => $option['add_on'] ?? null, + 'compatible_os_families' => $this->normalizedOsFamilies($option['compatible_os_families'] ?? []), + 'requires_image' => (bool) ($option['requires_image'] ?? false), + 'requires_managed_stack_image' => (bool) ($option['requires_managed_stack_image'] ?? false), + 'automated' => (bool) ($option['automated'] ?? false), + ]; + } + + return $items; + } + + /** + * @return array> + */ + private function imageOptions(HostingProduct $product): array + { + if ($this->availableImages !== null) { + return $this->availableImages; + } + + $images = []; + + try { + foreach ($this->contaboProvider->getAvailableImages() as $image) { + $rule = $this->matchImageRule($image); + + $images[] = [ + 'value' => (string) $image['id'], + 'label' => (string) ($image['name'] ?? ($rule['label'] ?? 'Image')), + 'description' => (string) ($image['description'] ?? ($rule['label'] ?? '')), + 'image_id' => (string) $image['id'], + 'os_family' => (string) ($rule['os_family'] ?? 'linux'), + 'default_user' => (string) ($rule['default_user'] ?? 'root'), + 'monthly_usd' => (float) ($rule['monthly_usd'] ?? 0), + 'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($rule['monthly_usd'] ?? 0)), + 'automated' => true, + 'add_on' => ! empty($rule['requires_custom_image_addon']) ? 'customImage' : null, + ]; + } + } catch (\Throwable) { + // Fall back to configured image list when the API is unavailable. + } + + if ($images !== []) { + usort($images, fn (array $a, array $b): int => strcmp((string) ($a['label'] ?? ''), (string) ($b['label'] ?? ''))); + + return $this->availableImages = $this->prependInstallLaterImage($product, $images); + } + + $fallback = []; + foreach ((array) config('hosting.server_order.fallback_images', []) as $image) { + $fallback[] = [ + 'value' => (string) ($image['value'] ?? ''), + 'label' => (string) ($image['label'] ?? 'Image'), + 'description' => (string) ($image['description'] ?? ''), + 'image_id' => (string) ($image['value'] ?? ''), + 'os_family' => (string) ($image['os_family'] ?? 'linux'), + 'default_user' => (string) ($image['default_user'] ?? 'root'), + 'monthly_usd' => (float) ($image['monthly_usd'] ?? 0), + 'prices' => $this->cyclePricesForRecurringUsd($product, (float) ($image['monthly_usd'] ?? 0)), + 'automated' => (bool) ($image['automated'] ?? false), + 'add_on' => ! empty($image['requires_custom_image_addon']) ? 'customImage' : null, + ]; + } + + return $this->availableImages = $this->prependInstallLaterImage($product, $fallback); + } + + /** + * @param array $image + * @return array + */ + private function matchImageRule(array $image): array + { + $name = strtolower(trim((string) ($image['name'] ?? ''))); + $standardImage = (bool) ($image['standard_image'] ?? true); + $rules = (array) config('hosting.server_order.image_pricing_rules', []); + + foreach ($rules as $rule) { + $isCustomRule = (bool) ($rule['custom_image'] ?? false); + if ($isCustomRule && ! $standardImage) { + return $rule; + } + + foreach ((array) ($rule['match'] ?? []) as $needle) { + if ($needle !== '' && str_contains($name, strtolower((string) $needle))) { + return $rule; + } + } + } + + return [ + 'label' => $image['name'] ?? 'Image', + 'monthly_usd' => 0, + 'os_family' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'windows' : 'linux', + 'default_user' => str_contains(strtolower((string) ($image['os_type'] ?? 'linux')), 'win') ? 'administrator' : 'root', + 'requires_custom_image_addon' => ! $standardImage, + ]; + } + + /** + * @return array> + */ + private function defaultUserOptions(HostingProduct $product, string $osFamily): array + { + $configured = str_starts_with($osFamily, 'win') + ? (array) config('hosting.server_order.windows_default_users', []) + : (array) config('hosting.server_order.linux_default_users', []); + + $options = []; + + foreach ($configured as $value => $option) { + $options[] = [ + 'value' => (string) $value, + 'label' => (string) ($option['label'] ?? $value), + ]; + } + + return $options; + } + + /** + * @return array> + */ + private function applicationOptions(HostingProduct $product): array + { + $configApps = (array) config('hosting.server_order.applications', []); + $cachedIds = \Illuminate\Support\Facades\Cache::get('contabo_blockchain_app_ids', []); + + if ($this->hasMissingConfiguredApplicationIds($configApps)) { + $resolvedIds = $this->resolveApplicationIdsFromCatalog($configApps); + if ($resolvedIds !== []) { + $cachedIds = array_merge($cachedIds, $resolvedIds); + \Illuminate\Support\Facades\Cache::put('contabo_blockchain_app_ids', $cachedIds, now()->addDays(7)); + } + } + + foreach ($configApps as $key => $option) { + $appId = $option['application_id'] ?? null; + if (($appId === null || $appId === '') && isset($cachedIds[$key])) { + $configApps[$key]['application_id'] = $cachedIds[$key]['id']; + $appId = $configApps[$key]['application_id']; + } + + $preset = trim((string) ($option['cloud_init_preset'] ?? '')); + $resolvedAppId = trim((string) $appId); + + if ($key !== 'none' && $resolvedAppId === '' && $preset === '') { + $configApps[$key]['allow_unresolved'] = true; + $configApps[$key]['automated'] = false; + + $description = trim((string) ($configApps[$key]['description'] ?? '')); + if ($description !== '' && ! str_contains(strtolower($description), 'manual')) { + $configApps[$key]['description'] = $description.' Manual setup may be required after provisioning.'; + } + } + } + + return $this->optionCollection($product, $configApps); + } + + /** + * @param array> $configApps + */ + private function hasMissingConfiguredApplicationIds(array $configApps): bool + { + foreach ($configApps as $key => $option) { + if (! array_key_exists($key, self::APPLICATION_NAME_MAPPINGS)) { + continue; + } + + if (trim((string) ($option['application_id'] ?? '')) === '') { + return true; + } + } + + return false; + } + + /** + * @param array> $configApps + * @return array + */ + private function resolveApplicationIdsFromCatalog(array $configApps): array + { + try { + $applications = $this->contaboProvider->getAvailableApplications(); + } catch (\Throwable) { + return []; + } + + $mapped = []; + + foreach (self::APPLICATION_NAME_MAPPINGS as $configKey => $searchTerms) { + if (! isset($configApps[$configKey])) { + continue; + } + + if (trim((string) ($configApps[$configKey]['application_id'] ?? '')) !== '') { + continue; + } + + foreach ($applications as $app) { + $name = strtolower((string) ($app['name'] ?? '')); + foreach ($searchTerms as $term) { + if ($term !== '' && str_contains($name, strtolower($term))) { + $mapped[$configKey] = [ + 'id' => (string) ($app['id'] ?? ''), + 'name' => (string) ($app['name'] ?? $configKey), + 'description' => (string) ($app['description'] ?? ''), + ]; + break 2; + } + } + } + } + + return array_filter($mapped, static fn (array $app): bool => $app['id'] !== ''); + } + + /** + * @param array> $images + * @return array|null + */ + private function defaultImageOption(array $images): ?array + { + foreach ($images as $image) { + if (! ($image['is_install_later'] ?? false)) { + return $image; + } + } + + return $images[0] ?? null; + } + + /** + * @param array> $images + * @return array> + */ + private function prependInstallLaterImage(HostingProduct $product, array $images): array + { + array_unshift($images, [ + 'value' => self::INSTALL_LATER_IMAGE, + 'label' => 'Install Later', + 'description' => 'Provision the server first and choose the final OS installation afterward.', + 'image_id' => null, + 'os_family' => 'linux', + 'default_user' => 'root', + 'monthly_usd' => 0.0, + 'prices' => $this->cyclePricesForRecurringUsd($product, 0.0), + 'automated' => true, + 'add_on' => null, + 'is_install_later' => true, + ]); + + return $images; + } + + /** + * @param array $selection + * @param array $catalog + */ + private function assertCompatibleSelections(array $selection, array $catalog): void + { + $image = $this->findOption($catalog['images'], (string) ($selection['image'] ?? '')); + $license = $this->findOption($catalog['licenses'], (string) ($selection['license'] ?? '')); + $application = $this->findOption($catalog['applications'], (string) ($selection['application'] ?? '')); + + $errors = []; + + if (! $this->optionSupportsImage($license, $image)) { + $errors['license'] = 'The selected control panel is not compatible with this image.'; + } + + if (! $this->optionSupportsImage($application, $image)) { + $errors['application'] = 'The selected preinstalled app is not compatible with this image.'; + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** + * @param array|null $option + * @param array|null $image + */ + private function optionSupportsImage(?array $option, ?array $image): bool + { + if (! $option) { + return true; + } + + $imageLabel = strtolower((string) ($image['label'] ?? '')); + $bundledPanel = $this->bundledPanelFromImageLabel($imageLabel); + $optionLicense = strtolower((string) ($option['license'] ?? '')); + + if (($option['requires_image'] ?? false) && ($image['is_install_later'] ?? false)) { + return false; + } + + if (($option['requires_managed_stack_image'] ?? false) && ! $this->imageSupportsManagedStack($image)) { + return false; + } + + if ($bundledPanel !== null && (string) ($option['value'] ?? '') === 'none') { + return false; + } + + if ($bundledPanel === 'cpanel' && str_starts_with($optionLicense, 'plesk')) { + return false; + } + + if ($bundledPanel === 'plesk' && str_starts_with($optionLicense, 'cpanel')) { + return false; + } + + $families = (array) ($option['compatible_os_families'] ?? []); + if ($families === []) { + return true; + } + + $osFamily = strtolower((string) ($image['os_family'] ?? 'linux')); + foreach ($families as $family) { + if ($family !== '' && str_starts_with($osFamily, strtolower((string) $family))) { + return true; + } + } + + return false; + } + + /** + * @param array|null $image + */ + private function imageSupportsManagedStack(?array $image): bool + { + if (! $image || ($image['is_install_later'] ?? false)) { + return false; + } + + $imageLabel = strtolower((string) ($image['label'] ?? '')); + $osFamily = strtolower((string) ($image['os_family'] ?? 'linux')); + + if (str_starts_with($osFamily, 'win') || $this->imageIncludesVendorPanel($imageLabel)) { + return false; + } + + foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) { + $supportedOsFamily = strtolower((string) ($supported['os_family'] ?? 'linux')); + if ($supportedOsFamily !== '' && $supportedOsFamily !== $osFamily) { + continue; + } + + foreach ((array) ($supported['match'] ?? []) as $needle) { + $needle = strtolower(trim((string) $needle)); + if ($needle !== '' && str_contains($imageLabel, $needle)) { + return true; + } + } + } + + return false; + } + + private function imageIncludesVendorPanel(string $imageLabel): bool + { + return $this->bundledPanelFromImageLabel($imageLabel) !== null; + } + + private function bundledPanelFromImageLabel(string $imageLabel): ?string + { + if (str_contains($imageLabel, 'cpanel') || str_contains($imageLabel, 'whm')) { + return 'cpanel'; + } + + if (str_contains($imageLabel, 'plesk')) { + return 'plesk'; + } + + return null; + } + + /** + * @param array $option + */ + private function optionIsAvailable(array $option, string $value): bool + { + if ($value === 'none') { + return true; + } + + if (! empty($option['allow_unresolved'])) { + return true; + } + + $applicationId = trim((string) ($option['application_id'] ?? '')); + $preset = trim((string) ($option['cloud_init_preset'] ?? '')); + + if ($applicationId !== '' || $preset !== '') { + return true; + } + + return array_key_exists('application_id', $option) || array_key_exists('cloud_init_preset', $option) + ? false + : true; + } + + /** + * @param mixed $families + * @return array + */ + private function normalizedOsFamilies(mixed $families): array + { + $items = is_array($families) ? $families : []; + + return array_values(array_filter(array_map( + static fn ($family): string => strtolower(trim((string) $family)), + $items + ))); + } + + /** + * @param array $option + */ + private function cloudInitForOption(array $option, string $defaultUser): ?string + { + $preset = (string) ($option['cloud_init_preset'] ?? ''); + if ($preset === '') { + return null; + } + + return match ($preset) { + 'webmin' => $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget'], + 'run_commands' => [ + 'bash -lc \'if command -v apt-get >/dev/null 2>&1; then curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi\'', + ], + ]), + 'webmin_lamp' => $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget'], + 'run_commands' => [ + 'bash -lc \'if command -v apt-get >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y apache2 mariadb-server php libapache2-mod-php php-mysql curl wget && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y webmin; elif command -v dnf >/dev/null 2>&1; then dnf install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && dnf install -y webmin; elif command -v yum >/dev/null 2>&1; then yum install -y httpd mariadb-server php php-mysqlnd curl wget perl perl-Net-SSLeay openssl perl-Encode-Detect perl-Data-Dumper && curl -fsSL https://raw.githubusercontent.com/webmin/webmin/master/setup-repos.sh | sh && yum install -y webmin; fi && (systemctl enable --now apache2 || systemctl enable --now httpd || true) && (systemctl enable --now mariadb || systemctl enable --now mysqld || true)\'', + ], + ]), + default => $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget'], + 'run_commands' => [ + sprintf('echo "Preset %s selected for %s"', $preset, $defaultUser), + ], + ]), + }; + } + + /** + * @param array|null $option + * @return array|null + */ + private function selectionSummaryItem(string $key, string $label, ?array $option, bool $priceOptional = false): ?array + { + if (! $option) { + return null; + } + + $amount = (float) ($option['prices'][CustomerHostingOrder::CYCLE_MONTHLY] ?? 0); + + if (! $priceOptional && ($option['label'] ?? null) === null) { + return null; + } + + return [ + 'key' => $key, + 'label' => $label, + 'value' => (string) ($option['label'] ?? $option['value'] ?? ''), + 'description' => (string) ($option['description'] ?? ''), + 'automated' => (bool) ($option['automated'] ?? true), + 'monthly_amount' => round($amount, 2), + ]; + } + + /** + * @return array + */ + private function cyclePricesForRecurringUsd(HostingProduct $product, float $monthlyUsd): array + { + $monthlyGhs = $monthlyUsd > 0 + ? $this->pricing->convertUsdRecurringOption($monthlyUsd, $product->category) + : 0.0; + + return [ + CustomerHostingOrder::CYCLE_MONTHLY => round($monthlyGhs, 2), + CustomerHostingOrder::CYCLE_QUARTERLY => round($monthlyGhs * 3, 2), + CustomerHostingOrder::CYCLE_SEMIANNUAL => round($monthlyGhs * 6, 2), + CustomerHostingOrder::CYCLE_YEARLY => round($monthlyGhs * 12, 2), + ]; + } +} diff --git a/app/Services/Hosting/ServerPanelCapabilityService.php b/app/Services/Hosting/ServerPanelCapabilityService.php new file mode 100644 index 0000000..ca0a09b --- /dev/null +++ b/app/Services/Hosting/ServerPanelCapabilityService.php @@ -0,0 +1,181 @@ + $selection + * @param array $catalog + * @return array + */ + public function describeSelection(HostingProduct $product, array $selection, array $catalog): array + { + $license = $this->findOption((array) ($catalog['licenses'] ?? []), (string) ($selection['license'] ?? '')); + $image = $this->findOption((array) ($catalog['images'] ?? []), (string) ($selection['image'] ?? '')); + + $licenseLabel = (string) ($license['label'] ?? 'Remote Login Only'); + $panel = (string) ($license['panel'] ?? ''); + $vendorLicense = (string) ($license['license'] ?? ''); + $osFamily = strtolower((string) ($image['os_family'] ?? 'linux')); + $installLater = (bool) ($image['is_install_later'] ?? false); + $supportedManagedImage = $this->supportedManagedImage($image); + $linuxManagedCandidate = $panel === 'ladill' + && ! $installLater + && ! str_starts_with($osFamily, 'win') + && $supportedManagedImage !== null; + $supportedManagedImageLabels = array_values(array_filter(array_map( + static fn (array $supported): string => (string) ($supported['label'] ?? ''), + (array) config('hosting.server_order.managed_stack_supported_images', []) + ))); + + if ($panel === 'ladill') { + return [ + 'mode' => 'server_manager', + 'label' => $licenseLabel, + 'runtime' => 'provider_api', + 'is_ladill' => true, + 'managed_stack_candidate' => $linuxManagedCandidate, + 'managed_stack_supported_image' => $supportedManagedImage, + 'managed_stack_supported_image_labels' => $supportedManagedImageLabels, + 'hosting_panel_ready' => $linuxManagedCandidate, + 'future_hosting_panel_status' => $linuxManagedCandidate ? 'managed_stack_supported' : 'not_available', + 'current_capabilities' => [ + 'Power controls', + 'Status sync', + 'Instance details', + 'SSH access', + ], + 'future_capabilities' => $linuxManagedCandidate ? self::FUTURE_HOSTING_PANEL_CAPABILITIES : [], + 'primary_message' => $linuxManagedCandidate + ? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.' + : 'Ladill Server Manager is not available for the selected image.', + 'secondary_message' => $linuxManagedCandidate + ? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.' + : 'Choose a plain supported Linux image without cPanel or Plesk. Supported Ladill-managed images: '.implode(', ', $supportedManagedImageLabels).'.', + 'manager_cta_label' => 'Open Server Manager', + 'hosting_panel_target_label' => 'Ladill Hosting Panel', + 'product_type' => $product->type, + ]; + } + + if ($vendorLicense !== '') { + return [ + 'mode' => 'vendor_panel', + 'label' => $licenseLabel, + 'runtime' => 'in_server_panel', + 'is_ladill' => false, + 'managed_stack_candidate' => false, + 'hosting_panel_ready' => false, + 'future_hosting_panel_status' => 'external_panel', + 'current_capabilities' => [ + 'Provider provisioning', + 'Server login', + 'Installed panel management', + ], + 'future_capabilities' => [], + 'primary_message' => "{$licenseLabel} will be the panel running inside this server. Ladill will not replace it with the shared hosting panel.", + 'secondary_message' => 'Use Ladill for order tracking and server lifecycle, then manage sites and services from the installed panel itself.', + 'manager_cta_label' => 'Open Panel', + 'hosting_panel_target_label' => 'Ladill Hosting Panel', + 'product_type' => $product->type, + ]; + } + + return [ + 'mode' => 'remote_login_only', + 'label' => $licenseLabel, + 'runtime' => 'ssh_only', + 'is_ladill' => false, + 'managed_stack_candidate' => false, + 'hosting_panel_ready' => false, + 'future_hosting_panel_status' => 'not_enabled', + 'current_capabilities' => [ + 'Server login', + ], + 'future_capabilities' => [], + 'primary_message' => 'This server will be provisioned without a Ladill-managed or commercial control panel.', + 'secondary_message' => 'You will manage the machine directly over SSH or RDP until a panel or managed stack is installed separately.', + 'manager_cta_label' => 'Open Access Details', + 'hosting_panel_target_label' => 'Ladill Hosting Panel', + 'product_type' => $product->type, + ]; + } + + /** + * @param array> $options + * @return array|null + */ + private function findOption(array $options, string $value): ?array + { + foreach ($options as $option) { + if ((string) ($option['value'] ?? '') === $value) { + return $option; + } + } + + return null; + } + + /** + * @param array|null $image + * @return array|null + */ + private function supportedManagedImage(?array $image): ?array + { + if ($image === null) { + return null; + } + + $value = Str::lower((string) ($image['value'] ?? '')); + $label = Str::lower((string) ($image['label'] ?? '')); + $osFamily = Str::lower((string) ($image['os_family'] ?? '')); + + if ($this->imageIncludesVendorPanel($label)) { + return null; + } + + foreach ((array) config('hosting.server_order.managed_stack_supported_images', []) as $supported) { + if (($supported['os_family'] ?? 'linux') !== '' && Str::lower((string) ($supported['os_family'] ?? 'linux')) !== $osFamily) { + continue; + } + + $supportedValue = Str::lower((string) ($supported['value'] ?? '')); + if ($supportedValue !== '' && $supportedValue === $value) { + return $supported; + } + + foreach ((array) ($supported['match'] ?? []) as $needle) { + if ($needle !== '' && Str::contains($label, Str::lower((string) $needle))) { + return $supported; + } + } + } + + return null; + } + + private function imageIncludesVendorPanel(string $label): bool + { + foreach (['cpanel', 'plesk', 'whm'] as $needle) { + if ($needle !== '' && Str::contains($label, $needle)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/Hosting/ServerTaskDispatchService.php b/app/Services/Hosting/ServerTaskDispatchService.php new file mode 100644 index 0000000..2b9c83d --- /dev/null +++ b/app/Services/Hosting/ServerTaskDispatchService.php @@ -0,0 +1,224 @@ + + */ + public function claimTasks(ServerAgent $agent, int $limit = 10): Collection + { + $this->recoverStaleTasks($agent->customer_hosting_order_id); + + $claimedIds = DB::transaction(function () use ($agent, $limit): array { + $taskIds = ServerTask::query() + ->where('customer_hosting_order_id', $agent->customer_hosting_order_id) + ->where('status', ServerTask::STATUS_QUEUED) + ->where(function ($query): void { + $query->whereNull('available_at') + ->orWhere('available_at', '<=', now()); + }) + ->whereNull('lock_token') + ->orderBy('id') + ->limit($limit) + ->lockForUpdate() + ->pluck('id') + ->all(); + + $claimed = []; + + foreach ($taskIds as $taskId) { + $lockToken = Str::random(48); + + $updated = ServerTask::query() + ->whereKey($taskId) + ->whereNull('lock_token') + ->update([ + 'server_agent_id' => $agent->id, + 'status' => ServerTask::STATUS_DISPATCHED, + 'lock_token' => $lockToken, + 'locked_at' => now(), + 'attempt_count' => DB::raw('attempt_count + 1'), + 'started_at' => DB::raw('COALESCE(started_at, CURRENT_TIMESTAMP)'), + 'last_heartbeat_at' => now(), + ]); + + if ($updated) { + $claimed[] = $taskId; + } + } + + return $claimed; + }); + + return ServerTask::query()->whereIn('id', $claimedIds)->orderBy('id')->get(); + } + + public function markProgress(ServerTask $task, string $lockToken, int $progress, ?string $message = null, array $result = []): ServerTask + { + $task = $this->assertLock($task, $lockToken); + + $task->update([ + 'status' => ServerTask::STATUS_RUNNING, + 'progress' => $progress, + 'result' => $this->mergedTaskResult($task, $message, $result), + 'started_at' => $task->started_at ?? now(), + 'last_heartbeat_at' => now(), + 'locked_at' => now(), + ]); + + return $task->fresh(); + } + + public function markCompleted(ServerTask $task, string $lockToken, ?string $message = null, array $result = []): ServerTask + { + $task = $this->assertLock($task, $lockToken); + + $task->update([ + 'status' => ServerTask::STATUS_COMPLETED, + 'progress' => 100, + 'result' => $this->mergedTaskResult($task, $message, $result), + 'completed_at' => now(), + 'last_heartbeat_at' => now(), + 'error_message' => null, + 'lock_token' => null, + 'locked_at' => null, + ]); + + return $this->resultService->apply($task->fresh()); + } + + /** + * @return array{task: ServerTask, requeued: bool} + */ + public function markFailed(ServerTask $task, string $lockToken, string $message, array $result = [], bool $retryable = true): array + { + $task = $this->assertLock($task, $lockToken); + $mergedResult = $this->mergedTaskResult($task, $message, $result); + + if ($task->canRetry($retryable)) { + $task->update([ + 'status' => ServerTask::STATUS_QUEUED, + 'progress' => 0, + 'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)), + 'failed_at' => null, + 'last_heartbeat_at' => now(), + 'error_message' => $message, + 'result' => array_merge($mergedResult, [ + 'retrying' => true, + 'retry_scheduled_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds))->toIso8601String(), + ]), + 'lock_token' => null, + 'locked_at' => null, + ]); + + return [ + 'task' => $task->fresh(), + 'requeued' => true, + ]; + } + + $task->update([ + 'status' => ServerTask::STATUS_FAILED, + 'failed_at' => now(), + 'last_heartbeat_at' => now(), + 'error_message' => $message, + 'result' => array_merge($mergedResult, ['retrying' => false]), + 'lock_token' => null, + 'locked_at' => null, + ]); + + return [ + 'task' => $task->fresh(), + 'requeued' => false, + ]; + } + + public function recoverStaleTasks(int|CustomerHostingOrder $order): int + { + $orderId = $order instanceof CustomerHostingOrder ? $order->id : $order; + $tasks = ServerTask::query() + ->where('customer_hosting_order_id', $orderId) + ->whereIn('status', [ServerTask::STATUS_DISPATCHED, ServerTask::STATUS_RUNNING]) + ->get(); + + $recovered = 0; + + foreach ($tasks as $task) { + $referenceTime = $task->last_heartbeat_at ?? $task->locked_at ?? $task->updated_at; + if ($referenceTime === null) { + continue; + } + + if ($referenceTime->gt(now()->subSeconds(max(30, (int) $task->stale_after_seconds)))) { + continue; + } + + if ($task->canRetry()) { + $task->update([ + 'status' => ServerTask::STATUS_QUEUED, + 'progress' => 0, + 'server_agent_id' => null, + 'available_at' => now()->addSeconds(max(15, (int) $task->retry_backoff_seconds)), + 'error_message' => 'Recovered after agent heartbeat timeout.', + 'result' => array_merge((array) ($task->result ?? []), [ + 'recovered_from_stale_lock' => true, + 'stale_recovered_at' => now()->toIso8601String(), + ]), + 'lock_token' => null, + 'locked_at' => null, + 'last_heartbeat_at' => null, + ]); + } else { + $task->update([ + 'status' => ServerTask::STATUS_FAILED, + 'failed_at' => now(), + 'error_message' => 'Task failed after stale lock recovery exhausted its retry budget.', + 'result' => array_merge((array) ($task->result ?? []), [ + 'recovered_from_stale_lock' => true, + 'stale_recovered_at' => now()->toIso8601String(), + 'retrying' => false, + ]), + 'lock_token' => null, + 'locked_at' => null, + ]); + } + + $recovered++; + } + + return $recovered; + } + + private function assertLock(ServerTask $task, string $lockToken): ServerTask + { + $task->refresh(); + abort_if($task->lock_token === null, 409, 'Task lock has expired.'); + abort_unless(hash_equals((string) $task->lock_token, $lockToken), 409, 'Task lock token mismatch.'); + + return $task; + } + + /** + * @param array $result + * @return array + */ + private function mergedTaskResult(ServerTask $task, ?string $message, array $result): array + { + return array_filter(array_merge((array) ($task->result ?? []), $result, [ + 'message' => $message, + ]), static fn ($value): bool => $value !== null); + } +} diff --git a/app/Services/Hosting/ServerTaskResultService.php b/app/Services/Hosting/ServerTaskResultService.php new file mode 100644 index 0000000..ebef1ba --- /dev/null +++ b/app/Services/Hosting/ServerTaskResultService.php @@ -0,0 +1,557 @@ +loadMissing('order', 'agent'); + + $references = match ($task->type) { + ServerTask::TYPE_DOMAIN_ADD => $this->persistDomainTask($task), + ServerTask::TYPE_SITE_DELETE => $this->persistSiteDeleteTask($task), + ServerTask::TYPE_SSL_REQUEST => $this->persistSslTask($task), + ServerTask::TYPE_SSL_RENEW => $this->persistSslRenewTask($task), + ServerTask::TYPE_DATABASE_CREATE => $this->persistDatabaseTask($task), + ServerTask::TYPE_PHP_CONFIGURE => $this->persistPhpTask($task), + ServerTask::TYPE_CRON_LIST, + ServerTask::TYPE_CRON_UPSERT, + ServerTask::TYPE_CRON_REMOVE => $this->persistCronTask($task), + ServerTask::TYPE_LOG_READ => $this->persistLogTask($task), + ServerTask::TYPE_FILE_LIST, + ServerTask::TYPE_FILE_READ, + ServerTask::TYPE_FILE_WRITE => $this->persistFileTask($task), + default => [], + }; + + if ($references !== []) { + $result = (array) ($task->result ?? []); + $result['model_refs'] = array_merge((array) ($result['model_refs'] ?? []), $references); + $task->forceFill(['result' => $result])->save(); + } + + return $task->fresh(); + } + + /** + * @return array + */ + private function persistDomainTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', ''))); + if ($domainName === '') { + return []; + } + + $order = $task->order()->first(); + $domain = Domain::query()->where('host', $domainName)->first(); + + if ($domain === null && $order !== null) { + $domain = Domain::create([ + 'user_id' => $order->user_id, + 'host' => $domainName, + 'type' => 'server', + 'source' => 'server_manager', + 'status' => 'pending', + 'onboarding_mode' => Domain::MODE_MANUAL_DNS, + 'onboarding_state' => Domain::STATE_CONNECTED, + 'dns_mode' => 'manual', + 'connected_at' => now(), + 'verification_meta' => [ + 'source' => 'server_agent_task', + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + ], + ]); + } elseif ($domain !== null) { + $verificationMeta = array_merge((array) ($domain->verification_meta ?? []), [ + 'server_agent_task_id' => $task->id, + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + ]); + + $domain->fill([ + 'user_id' => $domain->user_id ?: $order?->user_id, + 'source' => $domain->source ?: 'server_manager', + 'connected_at' => $domain->connected_at ?? now(), + 'verification_meta' => $verificationMeta, + ])->save(); + } + + $site = ServerSite::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'domain' => $domainName, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'domain_id' => $domain?->id, + 'document_root' => (string) ($result['document_root'] ?? data_get($task->payload, 'document_root', '')) ?: null, + 'php_version' => (string) data_get($task->payload, 'php_version', '') ?: null, + 'php_socket' => (string) ($result['php_socket'] ?? '') ?: null, + 'status' => 'active', + 'metadata' => array_filter([ + 'task_type' => $task->type, + 'server_task_id' => $task->id, + 'payload' => $task->payload, + 'result' => Arr::except($result, ['message', 'model_refs']), + ], static fn ($value): bool => $value !== null && $value !== []), + ] + ); + + return array_filter([ + 'domain_id' => $domain?->id, + 'server_site_id' => $site->id, + ]); + } + + /** + * @return array + */ + private function persistSiteDeleteTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', ''))); + if ($domainName === '') { + return []; + } + + $site = ServerSite::query() + ->where('customer_hosting_order_id', $task->customer_hosting_order_id) + ->where('domain', $domainName) + ->first(); + + if ($site === null) { + return []; + } + + $metadata = array_merge((array) ($site->metadata ?? []), [ + 'removed_at' => now()->toIso8601String(), + 'removed_by_task_id' => $task->id, + ]); + + $site->fill([ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'status' => 'removed', + 'ssl_status' => null, + 'metadata' => $metadata, + ])->save(); + + return [ + 'server_site_id' => $site->id, + ]; + } + + /** + * @return array + */ + private function persistSslTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', ''))); + if ($domainName === '') { + return []; + } + + $site = ServerSite::query() + ->where('customer_hosting_order_id', $task->customer_hosting_order_id) + ->where('domain', $domainName) + ->first(); + + if ($site === null) { + $site = ServerSite::create([ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'domain' => $domainName, + 'status' => 'active', + 'ssl_status' => (string) ($result['ssl'] ?? 'issued'), + 'ssl_provisioned_at' => now(), + 'metadata' => [ + 'created_from' => 'ssl.request', + 'server_task_id' => $task->id, + ], + ]); + } else { + $site->fill([ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'ssl_status' => (string) ($result['ssl'] ?? 'issued'), + 'ssl_provisioned_at' => now(), + ])->save(); + } + + if ($site->domain_id) { + $domain = Domain::query()->find($site->domain_id); + } else { + $domain = Domain::query()->where('host', $domainName)->first(); + } + + if ($domain !== null) { + $domain->fill([ + 'ssl_status' => (string) ($result['ssl'] ?? 'issued'), + 'ssl_provisioned_at' => now(), + ])->save(); + + if ($site->domain_id === null) { + $site->forceFill(['domain_id' => $domain->id])->save(); + } + } + + return array_filter([ + 'domain_id' => $domain->id ?? null, + 'server_site_id' => $site->id, + ]); + } + + /** + * @return array + */ + private function persistSslRenewTask(ServerTask $task): array + { + $expiresAt = now()->addDays(90); + + ServerSite::query() + ->where('customer_hosting_order_id', $task->customer_hosting_order_id) + ->whereIn('ssl_status', ['issued', 'active']) + ->update([ + 'ssl_expires_at' => $expiresAt, + 'ssl_provisioned_at' => now(), + ]); + + $domainIds = ServerSite::query() + ->where('customer_hosting_order_id', $task->customer_hosting_order_id) + ->whereNotNull('domain_id') + ->pluck('domain_id'); + + if ($domainIds->isNotEmpty()) { + Domain::query() + ->whereIn('id', $domainIds) + ->where('ssl_status', 'active') + ->update(['ssl_expires_at' => $expiresAt]); + } + + return []; + } + + /** + * @return array + */ + private function persistDatabaseTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $databaseName = (string) ($result['database'] ?? data_get($task->payload, 'name', '')); + if ($databaseName === '') { + return []; + } + + $database = ServerDatabase::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'name' => $databaseName, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'username' => (string) ($result['username'] ?? $databaseName) ?: null, + 'password_encrypted' => isset($result['password']) && $result['password'] !== '' ? encrypt((string) $result['password']) : null, + 'engine' => 'mariadb', + 'status' => 'active', + 'metadata' => array_filter([ + 'server_task_id' => $task->id, + 'payload' => $task->payload, + 'result' => Arr::except($result, ['message', 'model_refs', 'password']), + ], static fn ($value): bool => $value !== null && $value !== []), + ] + ); + + return [ + 'server_database_id' => $database->id, + ]; + } + + /** + * @return array + */ + private function persistPhpTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $domainName = Str::lower((string) ($result['domain'] ?? data_get($task->payload, 'domain', ''))); + if ($domainName === '') { + return []; + } + + $site = ServerSite::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'domain' => $domainName, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'php_version' => (string) ($result['php_version'] ?? data_get($task->payload, 'php_version', '')) ?: null, + 'php_socket' => (string) ($result['php_socket'] ?? '') ?: null, + 'status' => 'active', + 'metadata' => array_filter([ + 'php_configured_at' => now()->toIso8601String(), + 'server_task_id' => $task->id, + 'result' => Arr::except($result, ['message', 'model_refs']), + ], static fn ($value): bool => $value !== null && $value !== []), + ] + ); + + return [ + 'server_site_id' => $site->id, + ]; + } + + /** + * @return array + */ + private function persistCronTask(ServerTask $task): array + { + $entries = array_values(array_filter((array) data_get($task->result, 'entries', []), 'is_array')); + $order = $task->order()->first(); + + if ($order !== null) { + $meta = (array) ($order->meta ?? []); + $panelState = (array) ($meta['server_panel'] ?? []); + $panelState['managed_cron_entries'] = $entries; + $panelState['managed_cron_synced_at'] = now()->toIso8601String(); + $meta['server_panel'] = $panelState; + $order->update(['meta' => $meta]); + } + + return [ + 'cron_entry_count' => count($entries), + ]; + } + + /** + * @return array + */ + private function persistLogTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + $contentBase64 = (string) ($result['content_base64'] ?? ''); + $content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false; + $content = is_string($content) ? $content : ''; + $target = (string) ($result['target'] ?? data_get($task->payload, 'target', 'log')); + $domain = (string) ($result['domain'] ?? data_get($task->payload, 'domain', '')); + $snapshotKey = $target.($domain !== '' ? ':'.$domain : ''); + $order = $task->order()->first(); + + if ($order !== null) { + $meta = (array) ($order->meta ?? []); + $panelState = (array) ($meta['server_panel'] ?? []); + $panelState['log_snapshots'] = array_merge((array) ($panelState['log_snapshots'] ?? []), [ + $snapshotKey => [ + 'target' => $target, + 'domain' => $domain !== '' ? $domain : null, + 'lines' => (int) ($result['lines'] ?? data_get($task->payload, 'lines', 0)), + 'path' => $result['path'] ?? null, + 'read_at' => now()->toIso8601String(), + 'content_preview' => $this->previewForContent($content), + ], + ]); + $meta['server_panel'] = $panelState; + $order->update(['meta' => $meta]); + } + + return [ + 'log_snapshot_key' => $snapshotKey, + ]; + } + + /** + * @return array + */ + private function persistFileTask(ServerTask $task): array + { + $result = (array) ($task->result ?? []); + + return match ($task->type) { + ServerTask::TYPE_FILE_LIST => $this->persistFileListTask($task, $result), + ServerTask::TYPE_FILE_READ => $this->persistFileReadTask($task, $result), + ServerTask::TYPE_FILE_WRITE => $this->persistFileWriteTask($task, $result), + default => [], + }; + } + + /** + * @param array $result + * @return array + */ + private function persistFileListTask(ServerTask $task, array $result): array + { + $basePath = (string) data_get($task->payload, 'path', ''); + $entries = array_values(array_filter((array) ($result['entries'] ?? []), 'is_array')); + $recordIds = []; + + if ($basePath !== '') { + $rootRecord = ServerFile::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'path' => $basePath, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'kind' => 'directory', + 'exists' => true, + 'metadata' => [ + 'entry_count' => count($entries), + 'server_task_id' => $task->id, + ], + 'last_synced_at' => now(), + ] + ); + $recordIds[] = $rootRecord->id; + } + + foreach ($entries as $entry) { + $path = (string) ($entry['path'] ?? ''); + if ($path === '') { + continue; + } + + $record = ServerFile::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'path' => $path, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'kind' => $this->normalizeFileKind((string) ($entry['type'] ?? 'f')), + 'exists' => true, + 'size_bytes' => is_numeric($entry['size'] ?? null) ? (int) $entry['size'] : null, + 'metadata' => [ + 'listed_name' => $entry['name'] ?? basename($path), + 'server_task_id' => $task->id, + ], + 'last_synced_at' => now(), + ] + ); + + $recordIds[] = $record->id; + } + + return [ + 'server_file_ids' => array_values(array_unique($recordIds)), + ]; + } + + /** + * @param array $result + * @return array + */ + private function persistFileReadTask(ServerTask $task, array $result): array + { + $path = (string) ($result['path'] ?? data_get($task->payload, 'path', '')); + if ($path === '') { + return []; + } + + $contentBase64 = (string) ($result['content_base64'] ?? ''); + $content = $contentBase64 !== '' ? base64_decode($contentBase64, true) : false; + $content = is_string($content) ? $content : ''; + + $file = ServerFile::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'path' => $path, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'kind' => 'file', + 'exists' => true, + 'size_bytes' => strlen($content), + 'content_hash' => $content !== '' ? hash('sha256', $content) : null, + 'content_preview' => $this->previewForContent($content), + 'metadata' => [ + 'encoding' => 'base64', + 'server_task_id' => $task->id, + ], + 'last_synced_at' => now(), + ] + ); + + return [ + 'server_file_id' => $file->id, + ]; + } + + /** + * @param array $result + * @return array + */ + private function persistFileWriteTask(ServerTask $task, array $result): array + { + $path = (string) ($result['path'] ?? data_get($task->payload, 'path', '')); + if ($path === '') { + return []; + } + + $content = (string) data_get($task->payload, 'content', ''); + $file = ServerFile::query()->updateOrCreate( + [ + 'customer_hosting_order_id' => $task->customer_hosting_order_id, + 'path' => $path, + ], + [ + 'server_agent_id' => $task->server_agent_id, + 'latest_server_task_id' => $task->id, + 'kind' => 'file', + 'exists' => true, + 'size_bytes' => strlen($content), + 'content_hash' => $content !== '' ? hash('sha256', $content) : null, + 'content_preview' => $this->previewForContent($content), + 'metadata' => [ + 'server_task_id' => $task->id, + 'write_source' => 'server_agent', + ], + 'last_synced_at' => now(), + ] + ); + + return [ + 'server_file_id' => $file->id, + ]; + } + + private function normalizeFileKind(string $type): string + { + return match ($type) { + 'd' => 'directory', + 'l' => 'symlink', + default => 'file', + }; + } + + private function previewForContent(string $content): ?string + { + if ($content === '') { + return null; + } + + if (preg_match('//u', $content) !== 1) { + return Str::limit(base64_encode(substr($content, 0, 2000)), 4000, ''); + } + + return (string) Str::of($content) + ->replaceMatches('/[^\P{C}\t\r\n]/u', '') + ->limit(4000, ''); + } +} diff --git a/app/Services/Hosting/Terminal/LocalPtyShellSession.php b/app/Services/Hosting/Terminal/LocalPtyShellSession.php new file mode 100644 index 0000000..12e5751 --- /dev/null +++ b/app/Services/Hosting/Terminal/LocalPtyShellSession.php @@ -0,0 +1,137 @@ + */ + private array $pipes = []; + + public function __construct( + private string $command, + ) {} + + public function boot(): void + { + $spec = [ + 0 => ['pipe', 'r'], + 1 => ['pty'], + 2 => ['pty'], + ]; + + $process = proc_open($this->command, $spec, $pipes, base_path()); + + if (! is_resource($process)) { + throw new \RuntimeException('Unable to start local PTY shell.'); + } + + $this->process = $process; + $this->pipes = $pipes; + + foreach ($this->pipes as $pipe) { + stream_set_blocking($pipe, false); + } + } + + public function read(): string + { + if (! $this->isAlive()) { + return ''; + } + + $output = ''; + $stream = $this->outputStream(); + + if (! $stream) { + return ''; + } + + $read = [$stream]; + + $write = null; + $except = null; + $available = @stream_select($read, $write, $except, 0, 200000); + + if ($available === false || $available === 0) { + return ''; + } + + $chunk = @stream_get_contents($stream); + + if ($chunk !== false && $chunk !== '') { + $output .= $chunk; + } + + return $output; + } + + public function write(string $input): void + { + $stream = $this->inputStream(); + + if ($input === '' || ! $stream) { + return; + } + + @fwrite($stream, $input); + @fflush($stream); + } + + public function resize(int $cols, int $rows): void + { + // proc_open PTYs do not expose a portable runtime resize API. + } + + public function isAlive(): bool + { + return is_resource($this->process) && (proc_get_status($this->process)['running'] ?? false); + } + + public function close(): void + { + $input = $this->inputStream(); + + if ($input) { + @fwrite($input, "exit\n"); + @fflush($input); + } + + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + @fclose($pipe); + } + } + + $this->pipes = []; + + if (is_resource($this->process)) { + @proc_terminate($this->process); + @proc_close($this->process); + } + + $this->process = null; + } + + /** @return resource|null */ + private function inputStream() + { + return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null; + } + + /** @return resource|null */ + private function outputStream() + { + if (isset($this->pipes[1]) && is_resource($this->pipes[1])) { + return $this->pipes[1]; + } + + if (isset($this->pipes[2]) && is_resource($this->pipes[2])) { + return $this->pipes[2]; + } + + return isset($this->pipes[0]) && is_resource($this->pipes[0]) ? $this->pipes[0] : null; + } +} diff --git a/app/Services/Hosting/Terminal/RemoteSshShellSession.php b/app/Services/Hosting/Terminal/RemoteSshShellSession.php new file mode 100644 index 0000000..2497b9f --- /dev/null +++ b/app/Services/Hosting/Terminal/RemoteSshShellSession.php @@ -0,0 +1,97 @@ +ssh->setWindowSize($this->cols, $this->rows); + $this->ssh->setTimeout(self::STARTUP_TIMEOUT_SECONDS); + $this->ssh->enablePTY(); + + if ($this->ssh->exec($this->launchCommand, false) !== true) { + throw new \RuntimeException('Unable to start remote terminal shell.'); + } + } + + public function read(): string + { + if (! $this->isAlive()) { + return ''; + } + + $output = ''; + $this->ssh->setTimeout(self::READ_TIMEOUT_SECONDS); + + while (true) { + $chunk = $this->ssh->read('', SSH2::READ_NEXT); + + if ($chunk === true) { + if ($this->ssh->isTimeout()) { + break; + } + + $this->closed = true; + break; + } + + if (! is_string($chunk) || $chunk === '') { + break; + } + + $output .= $chunk; + } + + return $output; + } + + public function write(string $input): void + { + if ($input === '' || ! $this->isAlive()) { + return; + } + + $this->ssh->write($input); + } + + public function resize(int $cols, int $rows): void + { + // phpseclib applies the PTY size during shell creation; runtime resize is left as a no-op + // to avoid injecting visible shell commands into the user session. + } + + public function isAlive(): bool + { + return ! $this->closed && $this->ssh->isConnected(); + } + + public function close(): void + { + if ($this->ssh->isConnected()) { + try { + $this->ssh->write("exit\n"); + } catch (\Throwable) { + // Ignore shutdown write failures. + } + + $this->ssh->disconnect(); + } + + $this->closed = true; + } +} diff --git a/app/Services/Hosting/Terminal/ShellSession.php b/app/Services/Hosting/Terminal/ShellSession.php new file mode 100644 index 0000000..3ea8c7b --- /dev/null +++ b/app/Services/Hosting/Terminal/ShellSession.php @@ -0,0 +1,18 @@ +> + */ + public function recommendationsForUser(User $user): Collection + { + $accountsQuery = HostingAccount::query() + ->where('user_id', $user->id) + ->where('status', HostingAccount::STATUS_ACTIVE) + ->with(['product']); + + if (HostingAccount::tracksLocalMailboxes()) { + $accountsQuery->with(['mailboxes:id,hosting_account_id,is_paid']); + } + + $accounts = $accountsQuery->get(); + + return $accounts + ->flatMap(fn (HostingAccount $account): array => $this->recommendationsForAccount($account)) + ->sortByDesc(fn (array $recommendation): float => (float) ($recommendation['priority_score'] ?? 0)) + ->values(); + } + + /** + * @return array> + */ + public function recommendationsForAccount(HostingAccount $account): array + { + $account->loadMissing(['product']); + + $recommendations = []; + $upgradeProduct = $this->nextUpgradeProduct($account); + $diskPercent = $account->diskUsagePercent(); + $trafficPercent = $this->bandwidthUsagePercent($account); + $trafficTriggered = $trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD; + $storageTriggered = $diskPercent >= self::STORAGE_THRESHOLD; + + if ($upgradeProduct && ($storageTriggered || $trafficTriggered)) { + $recommendations[] = [ + 'id' => sprintf('account-%d-upgrade', $account->id), + 'type' => 'plan_upgrade', + 'trigger' => $storageTriggered && $trafficTriggered + ? 'storage_and_traffic' + : ($storageTriggered ? 'storage_usage' : 'traffic_spike'), + 'account_id' => $account->id, + 'account_label' => $account->primary_domain ?: $account->username, + 'title' => $storageTriggered && $trafficTriggered + ? sprintf('%s is outgrowing its current plan', $account->primary_domain ?: $account->username) + : ($storageTriggered + ? sprintf('Storage usage is high on %s', $account->primary_domain ?: $account->username) + : sprintf('Traffic is climbing on %s', $account->primary_domain ?: $account->username)), + 'message' => $this->upgradeMessage($account, $upgradeProduct, $diskPercent, $trafficPercent), + 'cta_label' => sprintf('Upgrade to %s', $upgradeProduct->name), + 'cta_url' => route('hosting.accounts.show', $account), + 'target_product_id' => $upgradeProduct->id, + 'target_product_name' => $upgradeProduct->name, + 'target_product_type' => $upgradeProduct->type, + 'priority_score' => max($diskPercent, $trafficPercent ?? 0), + 'metrics' => [ + 'disk_usage_percent' => round($diskPercent, 2), + 'traffic_usage_percent' => $trafficPercent !== null ? round($trafficPercent, 2) : null, + ], + ]; + } + + $freeAllowance = $account->freeEmailAllowance(); + $mailboxCount = $account->loadedMailboxes()->count(); + if (HostingAccount::tracksLocalMailboxes() && $freeAllowance !== null && $mailboxCount > $freeAllowance) { + $extraMailboxes = $mailboxCount - $freeAllowance; + $addonAmount = self::EMAIL_ADDON_QUANTITY * self::EMAIL_ADDON_UNIT_PRICE; + + $recommendations[] = [ + 'id' => sprintf('account-%d-email-addon', $account->id), + 'type' => 'email_addon', + 'trigger' => 'email_overage', + 'account_id' => $account->id, + 'account_label' => $account->primary_domain ?: $account->username, + 'title' => sprintf('Mailbox overage on %s', $account->primary_domain ?: $account->username), + 'message' => sprintf( + 'This account is using %d mailbox%s on a plan with %d included. Adding %d more mailboxes would be GHS %d/month.', + $mailboxCount, + $mailboxCount === 1 ? '' : 'es', + $freeAllowance, + self::EMAIL_ADDON_QUANTITY, + $addonAmount + ), + 'cta_label' => 'Manage Email Add-ons', + 'cta_url' => route('hosting.accounts.show', $account), + 'suggested_quantity' => self::EMAIL_ADDON_QUANTITY, + 'estimated_amount' => (float) $addonAmount, + 'priority_score' => 50 + ($extraMailboxes * 5), + 'metrics' => [ + 'mailbox_count' => $mailboxCount, + 'free_allowance' => $freeAllowance, + 'extra_mailboxes' => $extraMailboxes, + ], + ]; + } + + return $recommendations; + } + + private function nextUpgradeProduct(HostingAccount $account): ?HostingProduct + { + $current = $account->product; + if (! $current) { + return null; + } + + return HostingProduct::query() + ->active() + ->visible() + ->where('category', HostingProduct::CATEGORY_SHARED) + ->whereKeyNot($current->id) + ->get() + ->filter(function (HostingProduct $candidate) use ($current): bool { + $candidateDomains = $candidate->max_domains ?? 0; + $currentDomains = $current->max_domains ?? 0; + + return ($candidate->disk_gb ?? 0) > ($current->disk_gb ?? 0) + || ($candidate->max_email_accounts ?? 0) > ($current->max_email_accounts ?? 0) + || ($candidateDomains === -1 && $currentDomains !== -1) + || ($candidateDomains > $currentDomains && $currentDomains !== -1); + }) + ->sortBy(fn (HostingProduct $candidate): string => sprintf( + '%d-%08d-%012.2f', + $candidate->type === $current->type ? 0 : 1, + (int) ($candidate->sort_order ?? 0), + (float) ($candidate->price_monthly ?? 0) + )) + ->first(); + } + + private function bandwidthUsagePercent(HostingAccount $account): ?float + { + $limitBytes = $this->bandwidthLimitBytes($account); + if ($limitBytes === null || $limitBytes <= 0) { + return null; + } + + return ($account->bandwidth_used_bytes / $limitBytes) * 100; + } + + private function bandwidthLimitBytes(HostingAccount $account): ?int + { + $limitBytes = data_get($account->resource_limits, 'bandwidth_limit_bytes'); + if (is_numeric($limitBytes) && (int) $limitBytes > 0) { + return (int) $limitBytes; + } + + $bandwidthGb = data_get($account->resource_limits, 'bandwidth_gb'); + if (is_numeric($bandwidthGb) && (int) $bandwidthGb > 0) { + return (int) $bandwidthGb * self::BYTES_PER_GB; + } + + if ($account->product?->bandwidth_gb && $account->product->bandwidth_gb > 0) { + return (int) $account->product->bandwidth_gb * self::BYTES_PER_GB; + } + + return null; + } + + private function upgradeMessage(HostingAccount $account, HostingProduct $target, float $diskPercent, ?float $trafficPercent): string + { + $parts = []; + + if ($diskPercent >= self::STORAGE_THRESHOLD) { + $parts[] = sprintf('disk usage is at %.1f%%', $diskPercent); + } + + if ($trafficPercent !== null && $trafficPercent >= self::TRAFFIC_THRESHOLD) { + $parts[] = sprintf('traffic usage is at %.1f%%', $trafficPercent); + } + + $reason = $parts === [] ? 'usage is increasing' : implode(' and ', $parts); + + return sprintf( + '%s. Moving this account to %s gives you %s storage%s.', + ucfirst($reason), + $target->name, + $target->formatDiskSize(), + $target->formatDomainLimit() ? ' and '.$target->formatDomainLimit() : '' + ); + } +} diff --git a/app/Services/Identity/IdentityClient.php b/app/Services/Identity/IdentityClient.php new file mode 100644 index 0000000..6033f57 --- /dev/null +++ b/app/Services/Identity/IdentityClient.php @@ -0,0 +1,57 @@ + */ + public function mailboxLinkStatus(string $publicId): array + { + $response = $this->request()->get($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + /** @return array */ + public function linkMailbox(string $publicId, string $mailboxAddress): array + { + $response = $this->request()->put($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + 'mailbox_address' => $mailboxAddress, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + /** @return array */ + public function unlinkMailbox(string $publicId): array + { + $response = $this->request()->delete($this->url('/identity/mailbox-link'), [ + 'user' => $publicId, + ]); + + $response->throw(); + + return (array) $response->json('data', []); + } + + private function request() + { + return Http::withToken((string) config('identity.api_key')) + ->acceptJson() + ->timeout(15); + } + + private function url(string $path): string + { + return rtrim((string) config('identity.api_url'), '/').$path; + } +} diff --git a/app/Services/Infrastructure/ContaboInfrastructureCloudInitBuilder.php b/app/Services/Infrastructure/ContaboInfrastructureCloudInitBuilder.php new file mode 100644 index 0000000..bdefd65 --- /dev/null +++ b/app/Services/Infrastructure/ContaboInfrastructureCloudInitBuilder.php @@ -0,0 +1,176 @@ +purpose) { + ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE => $this->buildHostingNode($provision), + ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY => $this->buildMailPrimary($provision), + ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION => $this->buildMailExtension($provision), + default => throw new \InvalidArgumentException("Unknown provision purpose: {$provision->purpose}"), + }; + } + + private function buildHostingNode(ContaboInfrastructureProvision $provision): string + { + $helperScript = File::get(base_path('deploy/bin/ladill-hosting-admin')); + $sudoers = "www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin *\n"; + $completionUrl = $provision->completionUrl(); + + return $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget', 'git', 'unzip', 'nginx', 'php-fpm', 'mysql-client', 'quota'], + 'ssh_keys' => array_filter([$provision->ssh_public_key]), + 'write_files' => [ + [ + 'path' => '/opt/ladill/ladill-hosting-admin', + 'permissions' => '0755', + 'owner' => 'root:root', + 'content' => $helperScript, + ], + [ + 'path' => '/opt/ladill/ladill-hosting.sudoers', + 'permissions' => '0440', + 'owner' => 'root:root', + 'content' => $sudoers, + ], + ], + 'run_commands' => [ + 'install -Dm755 /opt/ladill/ladill-hosting-admin /usr/local/bin/ladill-hosting-admin', + 'install -Dm440 /opt/ladill/ladill-hosting.sudoers /etc/sudoers.d/ladill-hosting', + 'visudo -cf /etc/sudoers.d/ladill-hosting', + $this->completionCurlCommand($completionUrl, ['bootstrap' => 'hosting']), + ], + ]); + } + + private function buildMailPrimary(ContaboInfrastructureProvision $provision): string + { + $payload = (array) ($provision->payload ?? []); + $mailHost = (string) ($payload['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com')); + $dbName = config('infrastructure.mail_stack.db_database', 'mail'); + $dbUser = config('infrastructure.mail_stack.db_username', 'mailuser'); + $dbPassword = (string) ($provision->generated_mail_db_password ?? ''); + $vmailRoot = config('infrastructure.mail_stack.vmail_root', '/var/vmail'); + $vmailUid = (string) config('infrastructure.mail_stack.vmail_uid', 5000); + $vmailGid = (string) config('infrastructure.mail_stack.vmail_gid', 5000); + + $exportClients = (string) ($payload['export_client_ips'] ?? ''); + + $bootstrapScript = str_replace( + [ + '__MAIL_HOST__', '__DB_PASSWORD__', '__DB_NAME__', '__DB_USER__', '__COMPLETION_URL__', + '__VMAIL_ROOT__', '__VMAIL_UID__', '__VMAIL_GID__', '__EXPORT_CLIENTS__', + ], + [ + $mailHost, $dbPassword, $dbName, $dbUser, $provision->completionUrl(), + $vmailRoot, $vmailUid, $vmailGid, $exportClients, + ], + File::get(resource_path('infrastructure/scripts/mail-primary-bootstrap.sh')) + ); + + $writeFiles = [ + $this->scriptFile('/opt/ladill/nfs-primary-setup.sh', 'infrastructure/scripts/nfs-primary-setup.sh'), + $this->scriptFile('/opt/ladill/vmail-usage-scan.sh', 'infrastructure/scripts/vmail-usage-scan.sh'), + [ + 'path' => '/opt/ladill/mail-primary-bootstrap.sh', + 'permissions' => '0755', + 'owner' => 'root:root', + 'encoding' => 'b64', + 'content' => base64_encode($bootstrapScript), + ], + ]; + + return $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget', 'jq', 'ca-certificates', 'sudo'], + 'ssh_keys' => array_filter([$provision->ssh_public_key]), + 'write_files' => $writeFiles, + 'run_commands' => [ + 'bash /opt/ladill/mail-primary-bootstrap.sh', + ], + ]); + } + + private function buildMailExtension(ContaboInfrastructureProvision $provision): string + { + $payload = (array) ($provision->payload ?? []); + $mailHost = (string) ($payload['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com')); + $primaryIp = (string) ($payload['primary_ip'] ?? ''); + $vmailRoot = config('infrastructure.mail_stack.vmail_root', '/var/vmail'); + $mountOpts = config('infrastructure.mail_stack.nfs_mount_options', 'nfsvers=4.1,rw,hard,timeo=600,retrans=2,_netdev'); + + if ($primaryIp === '' && ! empty($payload['primary_email_server_id'])) { + $primary = EmailServer::find($payload['primary_email_server_id']); + $primaryIp = $primary?->ip_address ?? ''; + $vmailRoot = $primary?->pool_vmail_root ?: $vmailRoot; + } + + $bootstrapScript = str_replace( + [ + '__MAIL_HOST__', '__PRIMARY_IP__', '__COMPLETION_URL__', + '__VMAIL_ROOT__', '__MOUNT_OPTS__', + ], + [ + $mailHost, $primaryIp, $provision->completionUrl(), + $vmailRoot, $mountOpts, + ], + File::get(resource_path('infrastructure/scripts/mail-extension-bootstrap.sh')) + ); + + $writeFiles = [ + $this->scriptFile('/opt/ladill/nfs-extension-setup.sh', 'infrastructure/scripts/nfs-extension-setup.sh'), + $this->scriptFile('/opt/ladill/vmail-usage-scan.sh', 'infrastructure/scripts/vmail-usage-scan.sh'), + [ + 'path' => '/opt/ladill/mail-extension-bootstrap.sh', + 'permissions' => '0755', + 'owner' => 'root:root', + 'encoding' => 'b64', + 'content' => base64_encode($bootstrapScript), + ], + ]; + + return $this->contaboProvider->generateCloudInit([ + 'packages' => ['curl', 'wget', 'jq', 'ca-certificates', 'sudo'], + 'ssh_keys' => array_filter([$provision->ssh_public_key]), + 'write_files' => $writeFiles, + 'run_commands' => [ + 'bash /opt/ladill/mail-extension-bootstrap.sh', + ], + ]); + } + + /** + * @return array + */ + private function scriptFile(string $remotePath, string $localRelativePath): array + { + return [ + 'path' => $remotePath, + 'permissions' => '0755', + 'owner' => 'root:root', + 'encoding' => 'b64', + 'content' => base64_encode(File::get(resource_path($localRelativePath))), + ]; + } + + /** + * @param array $extra + */ + private function completionCurlCommand(string $url, array $extra = []): string + { + $payload = json_encode(array_merge(['status' => 'ok'], $extra), JSON_UNESCAPED_SLASHES); + + return "curl -fsS -X POST ".escapeshellarg($url)." -H 'Content-Type: application/json' -d ".escapeshellarg($payload)." || true"; + } +} diff --git a/app/Services/Infrastructure/ContaboInfrastructureProvisioner.php b/app/Services/Infrastructure/ContaboInfrastructureProvisioner.php new file mode 100644 index 0000000..1fe1e38 --- /dev/null +++ b/app/Services/Infrastructure/ContaboInfrastructureProvisioner.php @@ -0,0 +1,350 @@ + $input + */ + public function startHostingNode(array $input, ?User $actor = null): ContaboInfrastructureProvision + { + $this->assertEnabled(); + + $keys = $this->sshKeys->generate(); + $placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254'); + + $node = HostingNode::create([ + 'name' => (string) $input['name'], + 'hostname' => (string) $input['hostname'], + 'ip_address' => $placeholderIp, + 'type' => (string) ($input['type'] ?? HostingNode::TYPE_SHARED), + 'role' => (string) ($input['role'] ?? HostingNode::ROLE_PRIMARY), + 'primary_hosting_node_id' => $input['primary_hosting_node_id'] ?? null, + 'segment' => (string) ($input['segment'] ?? HostingNode::SEGMENT_GENERAL), + 'provider' => HostingNode::PROVIDER_CONTABO, + 'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), + 'cpu_cores' => $input['cpu_cores'] ?? null, + 'ram_mb' => $input['ram_mb'] ?? null, + 'disk_gb' => $input['disk_gb'] ?? null, + 'bandwidth_tb' => $input['bandwidth_tb'] ?? null, + 'max_accounts' => (int) ($input['max_accounts'] ?? 100), + 'oversell_ratio' => (float) ($input['oversell_ratio'] ?? 3), + 'ssh_port' => 22, + 'ssh_private_key' => $keys['private_key'], + 'status' => HostingNode::STATUS_PROVISIONING, + ]); + + $provision = $this->createProvision( + purpose: ContaboInfrastructureProvision::PURPOSE_HOSTING_NODE, + name: (string) $input['name'], + productId: (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.hosting_product_id', 'V97')), + region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), + imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')), + payload: $input, + keys: $keys, + hostingNodeId: $node->id, + actor: $actor, + ); + + ProvisionContaboInfrastructureJob::dispatch($provision); + + return $provision; + } + + /** + * @param array $input + */ + public function startEmailServer(array $input, ?User $actor = null): ContaboInfrastructureProvision + { + $this->assertEnabled(); + + $isExtension = ($input['role'] ?? EmailServer::ROLE_PRIMARY) === EmailServer::ROLE_EXTENSION; + $keys = $this->sshKeys->generate(); + $mailHost = (string) ($input['mail_host'] ?? config('mailinfra.mail_host', 'mail.ladill.com')); + $placeholderIp = (string) config('infrastructure.contabo.placeholder_ip', '169.254.254.254'); + $mailDbPassword = $isExtension ? null : Str::password(32); + + $primary = null; + if ($isExtension) { + $primary = EmailServer::findOrFail($input['primary_email_server_id']); + } + + $server = EmailServer::create([ + 'name' => (string) $input['name'], + 'hostname' => (string) ($input['hostname'] ?? $mailHost), + 'ip_address' => $placeholderIp, + 'mail_host' => $mailHost, + 'role' => $isExtension ? EmailServer::ROLE_EXTENSION : EmailServer::ROLE_PRIMARY, + 'primary_email_server_id' => $primary?->id, + 'provider' => EmailServer::PROVIDER_CONTABO, + 'region' => (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), + 'cpu_cores' => $input['cpu_cores'] ?? null, + 'ram_mb' => $input['ram_mb'] ?? null, + 'disk_gb' => $input['disk_gb'] ?? null, + 'max_domains' => $input['max_domains'] ?? 5000, + 'max_mailboxes' => $input['max_mailboxes'] ?? 50000, + 'db_host' => $isExtension ? $primary?->db_host : config('infrastructure.mail_stack.db_host'), + 'db_port' => $primary?->db_port ?? 3306, + 'db_database' => config('infrastructure.mail_stack.db_database', 'mail'), + 'db_username' => $isExtension ? $primary?->db_username : config('infrastructure.mail_stack.db_username', 'mailuser'), + 'db_password' => $isExtension ? $primary?->db_password : $mailDbPassword, + 'ssh_host' => null, + 'ssh_user' => 'root', + 'ssh_port' => 22, + 'ssh_private_key' => $keys['private_key'], + 'status' => EmailServer::STATUS_PROVISIONING, + 'is_default' => ! $isExtension && (bool) ($input['is_default'] ?? false), + ]); + + if ($isExtension && $primary) { + $this->emailPool->syncExtensionFromPrimary($server, $primary); + } + + $purpose = $isExtension + ? ContaboInfrastructureProvision::PURPOSE_EMAIL_EXTENSION + : ContaboInfrastructureProvision::PURPOSE_EMAIL_PRIMARY; + + $productId = $isExtension + ? (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_extension_product_id', 'V94')) + : (string) ($input['contabo_product_id'] ?? config('infrastructure.contabo.mail_primary_product_id', 'V97')); + + $provision = $this->createProvision( + purpose: $purpose, + name: (string) $input['name'], + productId: $productId, + region: (string) ($input['region'] ?? config('infrastructure.contabo.default_region', 'EU')), + imageId: (string) ($input['image_id'] ?? config('infrastructure.contabo.default_image_id')), + payload: array_merge($input, [ + 'mail_host' => $mailHost, + 'primary_ip' => $primary?->ip_address, + 'primary_email_server_id' => $primary?->id, + ]), + keys: $keys, + emailServerId: $server->id, + mailDbPassword: $mailDbPassword, + actor: $actor, + ); + + ProvisionContaboInfrastructureJob::dispatch($provision); + + return $provision; + } + + public function createContaboInstance(ContaboInfrastructureProvision $provision): void + { + $provision->update(['status' => ContaboInfrastructureProvision::STATUS_CREATING]); + $provision->addLog('Creating Contabo compute instance…'); + + $userData = $this->cloudInit->build($provision); + + $result = $this->contabo->createInstance([ + 'product_id' => $provision->contabo_product_id, + 'region' => $provision->region, + 'image_id' => $provision->image_id, + 'display_name' => 'ladill-'.$provision->purpose.'-'.$provision->id, + 'user_data' => $userData, + 'default_user' => 'root', + ]); + + $provision->update([ + 'contabo_instance_id' => $result['instance_id'], + 'status' => ContaboInfrastructureProvision::STATUS_WAITING_IP, + 'instance_created_at' => now(), + ]); + + if ($provision->hosting_node_id) { + HostingNode::whereKey($provision->hosting_node_id)->update([ + 'provider_instance_id' => $result['instance_id'], + 'cpu_cores' => $result['cpu_cores'] ?? null, + 'ram_mb' => $result['ram_mb'] ?? null, + 'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null, + ]); + } + + if ($provision->email_server_id) { + EmailServer::whereKey($provision->email_server_id)->update([ + 'provider_instance_id' => $result['instance_id'], + 'cpu_cores' => $result['cpu_cores'] ?? null, + 'ram_mb' => $result['ram_mb'] ?? null, + 'disk_gb' => isset($result['disk_mb']) ? (int) ceil($result['disk_mb'] / 1024) : null, + ]); + } + + $provision->addLog('Instance '.$result['instance_id'].' created; waiting for IP address.'); + } + + public function pollInstanceIp(ContaboInfrastructureProvision $provision): bool + { + if (! $provision->contabo_instance_id) { + return false; + } + + $instance = $this->contabo->getInstance($provision->contabo_instance_id); + $ip = $instance['ip_address'] ?? null; + + if (! is_string($ip) || $ip === '' || $ip === '0.0.0.0') { + return false; + } + + $provision->update([ + 'status' => ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING, + 'ip_assigned_at' => now(), + ]); + $provision->addLog("Instance IP assigned: {$ip}"); + + if ($provision->hosting_node_id) { + HostingNode::whereKey($provision->hosting_node_id)->update([ + 'ip_address' => $ip, + 'ipv6_address' => $instance['ipv6_address'] ?? null, + ]); + } + + if ($provision->email_server_id) { + EmailServer::whereKey($provision->email_server_id)->update([ + 'ip_address' => $ip, + 'ipv6_address' => $instance['ipv6_address'] ?? null, + 'ssh_host' => $ip, + ]); + // Note: db_host is intentionally NOT set to the server's own IP. + // The mail DB is centralized; primaries keep the central db_host. + } + + return true; + } + + /** + * @param array $callback + */ + public function completeFromCallback(ContaboInfrastructureProvision $provision, array $callback): void + { + if ($provision->status === ContaboInfrastructureProvision::STATUS_COMPLETED) { + return; + } + + $provision->addLog('Bootstrap completion callback received.'); + + if ($provision->email_server_id) { + $server = EmailServer::find($provision->email_server_id); + if ($server?->isPrimary()) { + EmailServer::whereKey($server->id)->update([ + // Mail DB is centralized — point at the central host, not the server IP. + 'db_host' => config('infrastructure.mail_stack.db_host'), + 'ssh_host' => $server->ip_address, + ]); + $this->emailPool->syncExtensionCredentials($server->fresh()); + } + + if ($server?->isExtension() && $server->primary) { + $this->mailNfs->refreshPrimaryExports($server->primary); + } + } + + $this->activateResources($provision); + + $provision->update([ + 'status' => ContaboInfrastructureProvision::STATUS_COMPLETED, + 'completed_at' => now(), + ]); + $provision->addLog('Provisioning completed successfully.'); + } + + public function activateResources(ContaboInfrastructureProvision $provision): void + { + if ($provision->hosting_node_id) { + HostingNode::whereKey($provision->hosting_node_id)->update([ + 'status' => HostingNode::STATUS_ACTIVE, + ]); + } + + if ($provision->email_server_id) { + EmailServer::whereKey($provision->email_server_id)->update([ + 'status' => EmailServer::STATUS_ACTIVE, + ]); + } + } + + public function retry(ContaboInfrastructureProvision $provision): void + { + if ($provision->status !== ContaboInfrastructureProvision::STATUS_FAILED) { + return; + } + + $provision->update([ + 'status' => ContaboInfrastructureProvision::STATUS_PENDING, + 'error_message' => null, + 'failed_at' => null, + ]); + $provision->addLog('Retry requested.'); + + ProvisionContaboInfrastructureJob::dispatch($provision); + } + + /** + * @param array $payload + * @param array{public_key: string, private_key: string} $keys + */ + private function createProvision( + string $purpose, + string $name, + string $productId, + string $region, + ?string $imageId, + array $payload, + array $keys, + ?int $hostingNodeId = null, + ?int $emailServerId = null, + ?string $mailDbPassword = null, + ?User $actor = null, + ): ContaboInfrastructureProvision { + return ContaboInfrastructureProvision::create([ + 'purpose' => $purpose, + 'status' => ContaboInfrastructureProvision::STATUS_PENDING, + 'name' => $name, + 'contabo_product_id' => $productId, + 'region' => $region, + 'image_id' => $imageId ?: null, + 'completion_token' => Str::random(48), + 'payload' => $payload, + 'log' => [], + 'ssh_public_key' => $keys['public_key'], + 'ssh_private_key' => $keys['private_key'], + 'generated_mail_db_password' => $mailDbPassword, + 'hosting_node_id' => $hostingNodeId, + 'email_server_id' => $emailServerId, + 'created_by' => $actor?->id, + ]); + } + + private function assertEnabled(): void + { + if (! $this->isEnabled()) { + throw new \RuntimeException('Contabo infrastructure provisioning is not configured. Set CONTABO_* credentials in .env.'); + } + } +} diff --git a/app/Services/Infrastructure/ContaboInfrastructureSshKeyService.php b/app/Services/Infrastructure/ContaboInfrastructureSshKeyService.php new file mode 100644 index 0000000..deb4eaf --- /dev/null +++ b/app/Services/Infrastructure/ContaboInfrastructureSshKeyService.php @@ -0,0 +1,34 @@ + 4096, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($resource === false) { + throw new \RuntimeException('Failed to generate SSH key pair.'); + } + + openssl_pkey_export($resource, $privateKey); + $details = openssl_pkey_get_details($resource); + $publicKey = $details['key'] ?? ''; + + if ($publicKey === '') { + throw new \RuntimeException('Failed to export SSH public key.'); + } + + return [ + 'public_key' => trim($publicKey)."\n", + 'private_key' => $privateKey, + ]; + } +} diff --git a/app/Services/Infrastructure/InfrastructureUsageReportService.php b/app/Services/Infrastructure/InfrastructureUsageReportService.php new file mode 100644 index 0000000..0384493 --- /dev/null +++ b/app/Services/Infrastructure/InfrastructureUsageReportService.php @@ -0,0 +1,74 @@ + + */ + public function sharedHostingAccountsForNode(HostingNode $node): Collection + { + return HostingAccount::query() + ->where('hosting_node_id', $node->id) + ->where('type', HostingAccount::TYPE_SHARED) + ->with(['user:id,name,email', 'product:id,name']) + ->orderByDesc('disk_used_bytes') + ->get(); + } + + /** + * @return Collection + */ + public function mailStorageByUserForServer(EmailServer $server): Collection + { + $memberIds = app(\App\Services\Mail\EmailServerPoolService::class) + ->poolMembers($server->poolRoot()) + ->pluck('id'); + + $rows = Mailbox::query() + ->selectRaw('mailboxes.user_id') + ->selectRaw('COUNT(mailboxes.id) as mailbox_count') + ->selectRaw('COALESCE(SUM(mailboxes.quota_mb), 0) as allocated_storage_mb') + ->selectRaw('COALESCE(SUM(mailboxes.disk_used_bytes), 0) as used_storage_bytes') + ->where(function ($query) use ($memberIds) { + $query->whereHas('emailDomain', fn ($q) => $q->whereIn('email_server_id', $memberIds)) + ->orWhereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds)); + }) + ->groupBy('mailboxes.user_id') + ->orderByDesc('used_storage_bytes') + ->get(); + + $users = User::query() + ->whereIn('id', $rows->pluck('user_id')->filter()) + ->get() + ->keyBy('id'); + + return $rows->map(function ($row) use ($users) { + $user = $users->get($row->user_id); + + return (object) [ + 'user_id' => (int) $row->user_id, + 'name' => $user?->name, + 'email' => $user?->email, + 'mailbox_count' => (int) $row->mailbox_count, + 'allocated_storage_mb' => (int) $row->allocated_storage_mb, + 'used_storage_mb' => (int) ceil(((int) $row->used_storage_bytes) / 1048576), + ]; + }); + } +} diff --git a/app/Services/Mail/EmailServerPoolService.php b/app/Services/Mail/EmailServerPoolService.php new file mode 100644 index 0000000..a293239 --- /dev/null +++ b/app/Services/Mail/EmailServerPoolService.php @@ -0,0 +1,154 @@ +poolRoot(); + } + + /** + * @return Collection + */ + public function poolMembers(EmailServer $server): Collection + { + $root = $this->poolRoot($server); + + return EmailServer::query() + ->where(function ($query) use ($root) { + $query->where('id', $root->id) + ->orWhere('primary_email_server_id', $root->id); + }) + ->whereNotIn('status', [EmailServer::STATUS_DECOMMISSIONED]) + ->orderBy('role') + ->get(); + } + + public function poolDiskGb(EmailServer $server): int + { + return (int) $this->poolMembers($server)->sum('disk_gb'); + } + + public function poolUsedStorageMb(EmailServer $server): int + { + return (int) $this->poolMembers($server)->sum('used_storage_mb'); + } + + public function poolAllocatedStorageMb(EmailServer $server): int + { + $memberIds = $this->poolMembers($server)->pluck('id'); + + $fromEmailDomains = EmailDomain::query() + ->whereIn('email_server_id', $memberIds) + ->withSum('mailboxes as quota_sum', 'quota_mb') + ->get() + ->sum(fn (EmailDomain $d) => (int) ($d->quota_sum ?? 0)); + + $fromWebsiteDomains = Mailbox::query() + ->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds)) + ->sum('quota_mb'); + + return (int) ($fromEmailDomains + $fromWebsiteDomains); + } + + public function poolDomainCount(EmailServer $server): int + { + $memberIds = $this->poolMembers($server)->pluck('id'); + + $standalone = EmailDomain::query()->whereIn('email_server_id', $memberIds)->count(); + $website = \App\Models\Domain::query()->whereIn('email_server_id', $memberIds)->count(); + + return $standalone + $website; + } + + public function poolMailboxCount(EmailServer $server): int + { + $memberIds = $this->poolMembers($server)->pluck('id'); + + $standalone = Mailbox::query() + ->whereHas('emailDomain', fn ($q) => $q->whereIn('email_server_id', $memberIds)) + ->count(); + + $website = Mailbox::query() + ->whereHas('domain', fn ($q) => $q->whereIn('email_server_id', $memberIds)) + ->count(); + + return $standalone + $website; + } + + /** + * Pick the pool member with the most free physical disk for new mail storage. + */ + public function findStorageMember(EmailServer $poolRoot): ?EmailServer + { + $members = $this->poolMembers($poolRoot) + ->filter(fn (EmailServer $m) => in_array($m->status, [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL], true)); + + if ($members->isEmpty()) { + return null; + } + + return $members + ->sortByDesc(function (EmailServer $member) { + $disk = (int) ($member->disk_gb ?? 0); + $usedGb = $this->memberUsedStorageGb($member); + + return max($disk - $usedGb, 0); + }) + ->first(); + } + + public function memberUsedStorageGb(EmailServer $member): float + { + return ((int) $member->used_storage_mb) / 1024; + } + + public function poolPressurePercent(EmailServer $server): float + { + $poolDisk = $this->poolDiskGb($server); + if ($poolDisk <= 0) { + return 0; + } + + $usedGb = $this->poolUsedStorageMb($server) / 1024; + + return round(($usedGb / $poolDisk) * 100, 2); + } + + public function syncExtensionCredentials(EmailServer $primary): void + { + if (! $primary->isPrimary()) { + return; + } + + $primary->extensions()->update([ + 'mail_host' => $primary->mail_host, + 'db_host' => $primary->db_host, + 'db_port' => $primary->db_port, + 'db_database' => $primary->db_database, + 'db_username' => $primary->db_username, + 'db_password' => $primary->db_password, + ]); + } + + public function syncExtensionFromPrimary(EmailServer $extension, EmailServer $primary): void + { + $extension->update([ + 'mail_host' => $primary->mail_host, + 'role' => EmailServer::ROLE_EXTENSION, + 'primary_email_server_id' => $primary->id, + 'db_host' => $primary->db_host, + 'db_port' => $primary->db_port, + 'db_database' => $primary->db_database, + 'db_username' => $primary->db_username, + 'db_password' => $primary->db_password, + ]); + } +} diff --git a/app/Services/Mail/MailServerNfsService.php b/app/Services/Mail/MailServerNfsService.php new file mode 100644 index 0000000..5ebc146 --- /dev/null +++ b/app/Services/Mail/MailServerNfsService.php @@ -0,0 +1,58 @@ +poolRoot(); + + if (! $primary->isPrimary() || ! $this->ssh->canConnect($primary)) { + return; + } + + $clientIps = $this->pool->poolMembers($primary) + ->filter(fn (EmailServer $m) => $m->isExtension()) + ->pluck('ip_address') + ->filter() + ->unique() + ->values() + ->all(); + + if ($clientIps === []) { + return; + } + + $script = resource_path('infrastructure/scripts/nfs-refresh-exports.sh'); + $replacements = [ + '__VMAIL_ROOT__' => rtrim((string) ($primary->pool_vmail_root ?: config('infrastructure.mail_stack.vmail_root', '/var/vmail')), '/'), + '__NEW_CLIENTS__' => implode(' ', $clientIps), + ]; + + try { + $this->ssh->uploadAndRunScript( + $primary, + '/tmp/ladill-nfs-refresh-exports.sh', + $script, + $replacements, + ); + } catch (\Throwable $e) { + Log::warning('Failed to refresh NFS exports on primary mail server', [ + 'primary_id' => $primary->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Mail/MailServerProvisioner.php b/app/Services/Mail/MailServerProvisioner.php new file mode 100644 index 0000000..d784a09 --- /dev/null +++ b/app/Services/Mail/MailServerProvisioner.php @@ -0,0 +1,369 @@ +resolveServer(domain: $domain); + $name = rtrim($domain->host, '.'); + + $existing = $this->db($server)->table('domains')->where('name', $name)->first(); + + if ($existing) { + $this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]); + Log::info('MailServerProvisioner: Domain already exists, activated', ['domain' => $name, 'server_id' => $server->id]); + + return $existing->id; + } + + $id = $this->db($server)->table('domains')->insertGetId([ + 'name' => $name, + 'active' => 1, + 'created_at' => now(), + ]); + + Log::info('MailServerProvisioner: Domain provisioned', ['domain' => $name, 'server_id' => $server->id, 'id' => $id]); + + return $id; + } + + public function provisionMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int + { + $domain = $mailbox->domain; + if (! $domain) { + throw new \RuntimeException('Mailbox has no associated domain.'); + } + + $server = $this->resolveServer(domain: $domain); + $mailDomainId = $this->provisionDomain($domain); + $email = strtolower(trim($mailbox->address)); + $localPart = strtolower(trim($mailbox->local_part)); + $domainName = rtrim($domain->host, '.'); + $maildir = $this->maildirPath($server, $domainName, $localPart); + + return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain); + } + + public function provisionAlias(Domain $domain, string $source, string $destination): int + { + $server = $this->resolveServer(domain: $domain); + $mailDomainId = $this->provisionDomain($domain); + + $existing = $this->db($server)->table('aliases')->where('source', $source)->first(); + + if ($existing) { + $this->db($server)->table('aliases')->where('id', $existing->id)->update([ + 'destination' => $destination, + 'active' => 1, + ]); + + return $existing->id; + } + + return $this->db($server)->table('aliases')->insertGetId([ + 'domain_id' => $mailDomainId, + 'source' => $source, + 'destination' => $destination, + 'active' => 1, + 'created_at' => now(), + ]); + } + + public function deactivateDomain(Domain $domain): void + { + $server = $this->resolveServer(domain: $domain); + $name = rtrim($domain->host, '.'); + $this->db($server)->table('domains')->where('name', $name)->update(['active' => 0]); + Log::info('MailServerProvisioner: Domain deactivated', ['domain' => $name, 'server_id' => $server->id]); + } + + public function deactivateMailbox(Mailbox $mailbox): void + { + $server = $this->resolveServer(mailbox: $mailbox); + $email = strtolower(trim($mailbox->address)); + $this->db($server)->table('mailboxes')->where('email', $email)->update(['active' => 0]); + Log::info('MailServerProvisioner: Mailbox deactivated', ['email' => $email, 'server_id' => $server->id]); + } + + public function deployDkim(Domain $domain, DomainDkimKey $dkim): bool + { + $server = $this->resolveServer(domain: $domain); + $signingServer = $server->mailDatabaseServer(); + + return $this->deployDkimViaSsh($signingServer, rtrim($domain->host, '.'), $dkim); + } + + public function isConfigured(): bool + { + if (EmailServer::query()->whereIn('status', [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL])->exists()) { + return true; + } + + $password = config('database.connections.mailserver.password'); + + return is_string($password) && $password !== ''; + } + + public function provisionEmailDomain(EmailDomain $emailDomain): int + { + $server = $this->resolveServer(emailDomain: $emailDomain); + $name = rtrim($emailDomain->domain, '.'); + + $existing = $this->db($server)->table('domains')->where('name', $name)->first(); + + if ($existing) { + $this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]); + + return $existing->id; + } + + $id = $this->db($server)->table('domains')->insertGetId([ + 'name' => $name, + 'active' => 1, + 'created_at' => now(), + ]); + + Log::info('MailServerProvisioner: Email domain provisioned', ['domain' => $name, 'server_id' => $server->id]); + + return $id; + } + + public function provisionStandaloneMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int + { + $emailDomain = $mailbox->emailDomain; + if (! $emailDomain) { + throw new \RuntimeException('Standalone mailbox has no associated email domain.'); + } + + $server = $this->resolveServer(emailDomain: $emailDomain); + $mailDomainId = $this->provisionEmailDomain($emailDomain); + $email = strtolower(trim($mailbox->address)); + $localPart = strtolower(trim($mailbox->local_part)); + $domainName = rtrim($emailDomain->domain, '.'); + $maildir = $this->maildirPath($server, $domainName, $localPart); + + return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain); + } + + public function deprovisionEmailDomain(EmailDomain $emailDomain): void + { + $server = $this->resolveServer(emailDomain: $emailDomain); + $name = rtrim($emailDomain->domain, '.'); + + $domainRecord = $this->db($server)->table('domains')->where('name', $name)->first(); + if ($domainRecord) { + $this->db($server)->table('mailboxes')->where('domain_id', $domainRecord->id)->update(['active' => 0]); + $this->db($server)->table('domains')->where('id', $domainRecord->id)->update(['active' => 0]); + } + + Log::info('MailServerProvisioner: Email domain deprovisioned', ['domain' => $name, 'server_id' => $server->id]); + } + + private function upsertMailboxRecord( + EmailServer $server, + int $mailDomainId, + string $email, + string $localPart, + string $maildir, + int $quotaMb, + ?string $passwordPlain, + ): int { + $existing = $this->db($server)->table('mailboxes')->where('email', $email)->first(); + + if ($existing) { + $update = [ + 'active' => 1, + 'quota_mb' => $quotaMb, + 'maildir' => $maildir, + ]; + + if ($passwordPlain !== null && $passwordPlain !== '') { + $update['password_hash'] = $this->hashPassword($passwordPlain); + } + + $this->db($server)->table('mailboxes')->where('id', $existing->id)->update($update); + + return $existing->id; + } + + if ($passwordPlain === null || $passwordPlain === '') { + throw new \RuntimeException("Cannot provision new mailbox {$email} without a password."); + } + + $id = $this->db($server)->table('mailboxes')->insertGetId([ + 'domain_id' => $mailDomainId, + 'local_part' => $localPart, + 'email' => $email, + 'password_hash' => $this->hashPassword($passwordPlain), + 'quota_mb' => $quotaMb, + 'active' => 1, + 'maildir' => $maildir, + 'created_at' => now(), + ]); + + $this->capacity->refreshServerMetrics($server); + + return $id; + } + + private function maildirPath(EmailServer $server, string $domainName, string $localPart): string + { + return "{$domainName}/{$localPart}/"; + } + + private function deployDkimViaSsh(EmailServer $server, string $domainName, DomainDkimKey $dkim): bool + { + $sshHost = $server->ssh_host ?: $server->ip_address; + $sshUser = $server->ssh_user ?: 'root'; + $sshKey = $server->ssh_private_key; + + if (empty($sshHost) || empty($sshKey)) { + $sshHost = config('mailinfra.mail_ssh_host'); + $sshUser = config('mailinfra.mail_ssh_user', 'root'); + $sshKeyPath = config('mailinfra.mail_ssh_key_path'); + + if (empty($sshHost) || empty($sshKeyPath)) { + Log::warning('MailServerProvisioner: SSH not configured, skipping DKIM deployment', ['domain' => $domainName]); + + return false; + } + + return $this->deployDkimWithKeyPath($sshHost, $sshUser, $sshKeyPath, $domainName, $dkim); + } + + $tmpKey = tempnam(sys_get_temp_dir(), 'ladill-mail-ssh-'); + file_put_contents($tmpKey, $sshKey); + chmod($tmpKey, 0600); + + try { + return $this->deployDkimWithKeyPath($sshHost, $sshUser, $tmpKey, $domainName, $dkim); + } finally { + @unlink($tmpKey); + } + } + + private function deployDkimWithKeyPath(string $sshHost, string $sshUser, string $sshKeyPath, string $domainName, DomainDkimKey $dkim): bool + { + $selector = $dkim->selector; + $privatePath = $dkim->private_key_path; + + if (! $privatePath || ! file_exists($privatePath)) { + Log::warning('MailServerProvisioner: DKIM private key file not found', ['domain' => $domainName, 'path' => $privatePath]); + + return false; + } + + $privateKey = file_get_contents($privatePath); + $remoteKeyDir = "/etc/opendkim/keys/{$domainName}"; + $remoteKeyFile = "{$remoteKeyDir}/{$selector}.private"; + $escapedKey = base64_encode($privateKey); + $keyTableEntry = "{$selector}._domainkey.{$domainName} {$domainName}:{$selector}:{$remoteKeyFile}"; + $signingTableEntry = "*@{$domainName} {$selector}._domainkey.{$domainName}"; + + $commands = [ + "mkdir -p {$remoteKeyDir}", + "chown opendkim:opendkim {$remoteKeyDir}", + "echo '{$escapedKey}' | base64 -d > {$remoteKeyFile}", + "chown opendkim:opendkim {$remoteKeyFile}", + "chmod 600 {$remoteKeyFile}", + "grep -qF '{$domainName}' /etc/opendkim/key.table || echo '{$keyTableEntry}' >> /etc/opendkim/key.table", + "grep -qF '{$domainName}' /etc/opendkim/signing.table || echo '{$signingTableEntry}' >> /etc/opendkim/signing.table", + 'systemctl reload opendkim 2>/dev/null || service opendkim restart 2>/dev/null || true', + ]; + + $sshBase = "ssh -i {$sshKeyPath} -o StrictHostKeyChecking=no {$sshUser}@{$sshHost}"; + $fullCommand = $sshBase.' "'.implode(' && ', $commands).'"'; + + $output = []; + $exitCode = 0; + exec($fullCommand.' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + Log::error('MailServerProvisioner: DKIM deployment failed', [ + 'domain' => $domainName, + 'exit_code' => $exitCode, + 'output' => implode("\n", $output), + ]); + + return false; + } + + Log::info('MailServerProvisioner: DKIM deployed', ['domain' => $domainName, 'selector' => $selector]); + + return true; + } + + private function hashPassword(string $password): string + { + return '{BLF-CRYPT}'.password_hash($password, PASSWORD_BCRYPT); + } + + private function db(?EmailServer $server = null): ConnectionInterface + { + if ($server) { + return $this->connections->connection($server->mailDatabaseServer()); + } + + return DB::connection('mailserver'); + } + + private function resolveServer( + ?EmailServer $server = null, + ?EmailDomain $emailDomain = null, + ?Domain $domain = null, + ?Mailbox $mailbox = null, + ): EmailServer { + if ($server) { + return $server; + } + + if ($mailbox?->emailDomain?->emailServer) { + return $mailbox->emailDomain->emailServer; + } + + if ($mailbox?->domain?->emailServer) { + return $mailbox->domain->emailServer; + } + + if ($emailDomain) { + if ($emailDomain->email_server_id) { + return $emailDomain->emailServer; + } + + return $this->capacity->assignServerToEmailDomain($emailDomain); + } + + if ($domain) { + if ($domain->email_server_id) { + return $domain->emailServer; + } + + return $this->capacity->assignServerToDomain($domain); + } + + $fallback = EmailServer::available()->where('is_default', true)->first() + ?? EmailServer::available()->first(); + + if ($fallback) { + return $fallback; + } + + throw new \RuntimeException('No email server configured. Add a primary mail pool in Admin → Email Servers or set MAIL_DB_* in .env.'); + } +} diff --git a/app/Services/Mail/MailboxUserProvisioner.php b/app/Services/Mail/MailboxUserProvisioner.php new file mode 100644 index 0000000..2a3646a --- /dev/null +++ b/app/Services/Mail/MailboxUserProvisioner.php @@ -0,0 +1,113 @@ +ensureForAddress((string) $mailbox->address, (string) $mailbox->display_name, $password); + $this->syncHolderPassword($mailbox, $password); + + return $user; + } + + public function ensureForAddress(string $address, string $displayName = '', ?string $password = null): User + { + $email = strtolower(trim($address)); + if ($email === '' || ! str_contains($email, '@')) { + throw new \InvalidArgumentException('Invalid mailbox address.'); + } + + $attributes = [ + 'name' => ($name = trim($displayName) ?: Str::headline(Str::before($email, '@'))), + 'first_name' => Str::before($name, ' '), + 'last_name' => trim(Str::after($name, ' ')) ?: '', + 'password' => $password ?? Str::random(40), + ]; + + $user = User::firstOrCreate(['email' => $email], $attributes); + + if (! $user->wasRecentlyCreated && $password !== null && $password !== '') { + $user->password = $password; + $user->save(); + } + + if ($user->email_verified_at === null) { + $user->forceFill(['email_verified_at' => now()])->save(); + } + + return $user; + } + + /** + * Keep the Ladill account password aligned with the mailbox password. + * Uses the plaintext when available; otherwise copies the stored bcrypt hash. + */ + public function syncHolderPassword(Mailbox $mailbox, ?string $plaintextPassword = null): void + { + $email = strtolower(trim((string) $mailbox->address)); + if ($email === '' || ! str_contains($email, '@')) { + return; + } + + $user = User::query()->where('email', $email)->first(); + if (! $user) { + return; + } + + if ($plaintextPassword !== null && $plaintextPassword !== '') { + $user->password = $plaintextPassword; + $user->save(); + $this->vault->store($mailbox, $plaintextPassword); + + return; + } + + $hash = (string) $mailbox->password_hash; + if ($hash !== '') { + $this->assignPasswordHash($user, $hash); + } + } + + public function provisionSilently(string $address, string $displayName = '', ?string $password = null): ?User + { + try { + return $this->ensureForAddress($address, $displayName, $password); + } catch (\Throwable $e) { + report($e); + + return null; + } + } + + public function provisionMailboxSilently(Mailbox $mailbox, ?string $password = null): ?User + { + try { + return $this->ensureForMailbox($mailbox, $password); + } catch (\Throwable $e) { + report($e); + + return null; + } + } + + private function assignPasswordHash(User $user, string $passwordHash): void + { + $user->getConnection() + ->table($user->getTable()) + ->where($user->getKeyName(), $user->getKey()) + ->update(['password' => $passwordHash]); + } +} diff --git a/app/Services/Mailbox/MailboxClient.php b/app/Services/Mailbox/MailboxClient.php new file mode 100644 index 0000000..e23d823 --- /dev/null +++ b/app/Services/Mailbox/MailboxClient.php @@ -0,0 +1,95 @@ +acceptJson()->timeout(15); + } + + /** All mailboxes for the user (optionally scoped to one email domain). */ + public function forUser(string $publicId, ?int $emailDomainId = null): array + { + $q = ['user' => $publicId]; + if ($emailDomainId) { + $q['email_domain_id'] = $emailDomainId; + } + + return $this->data($this->http()->get($this->base(), $q)); + } + + public function show(string $publicId, int $id): array + { + return $this->data($this->http()->get($this->base().'/'.$id, ['user' => $publicId])); + } + + /** + * Create a mailbox. The Email app does its own wallet billing (§4-A) before + * calling and passes is_paid + payment_reference. Returns the mailbox detail. + */ + public function create(string $publicId, int $emailDomainId, string $localPart, string $displayName, string $password, ?int $quotaMb = null, bool $isPaid = false, ?string $paymentReference = null): array + { + $res = $this->http()->post($this->base(), array_filter([ + 'user' => $publicId, + 'email_domain_id' => $emailDomainId, + 'local_part' => $localPart, + 'display_name' => $displayName, + 'password' => $password, + 'quota_mb' => $quotaMb, + 'is_paid' => $isPaid, + 'payment_reference' => $paymentReference, + ], fn ($v) => $v !== null)); + $res->throw(); + + return (array) $res->json('data'); + } + + public function resetPassword(string $publicId, int $id, string $password): array + { + $res = $this->http()->patch($this->base().'/'.$id.'/password', ['user' => $publicId, 'password' => $password]); + $res->throw(); + + return (array) $res->json('data'); + } + + /** Change a mailbox's storage plan (quota). Billing is done before this call. */ + public function updateQuota(string $publicId, int $id, int $quotaMb, bool $isPaid = false, ?string $paymentReference = null): array + { + $res = $this->http()->patch($this->base().'/'.$id.'/quota', array_filter([ + 'user' => $publicId, + 'quota_mb' => $quotaMb, + 'is_paid' => $isPaid, + 'payment_reference' => $paymentReference, + ], fn ($v) => $v !== null)); + $res->throw(); + + return (array) $res->json('data'); + } + + public function delete(string $publicId, int $id): void + { + $this->http()->delete($this->base().'/'.$id, ['user' => $publicId])->throw(); + } + + private function data($res): array + { + $res->throw(); + + return (array) ($res->json('data') ?? $res->json()); + } +} diff --git a/app/Services/Payment/WalletPaymentService.php b/app/Services/Payment/WalletPaymentService.php new file mode 100644 index 0000000..0c6a970 --- /dev/null +++ b/app/Services/Payment/WalletPaymentService.php @@ -0,0 +1,99 @@ +billing->balanceMinor($user->public_id); + } catch (\Throwable $e) { + Log::warning('WalletPaymentService: balance failed', ['error' => $e->getMessage()]); + + return 0; + } + } + + public function canAfford(User $user, int $amountMinor): bool + { + if ($amountMinor <= 0) { + return true; + } + if (! $this->isConfigured()) { + return false; + } + + try { + return $this->billing->canAfford($user->public_id, $amountMinor); + } catch (\Throwable $e) { + Log::warning('WalletPaymentService: canAfford failed', ['error' => $e->getMessage()]); + + return false; + } + } + + /** + * Debit the wallet. Returns true on success, false on insufficient balance. + * Idempotent by $reference. + */ + public function charge(User $user, int $amountMinor, string $reference, string $source, string $description): bool + { + if ($amountMinor <= 0) { + return true; + } + if (! $this->isConfigured()) { + return false; + } + + try { + return $this->billing->debit( + publicId: $user->public_id, + amountMinor: $amountMinor, + service: $this->service(), + source: $source, + reference: $reference, + description: $description, + ); + } catch (\Throwable $e) { + Log::error('WalletPaymentService: charge failed', ['reference' => $reference, 'error' => $e->getMessage()]); + + return false; + } + } + + /** Refund (e.g. if provisioning fails after charge). Idempotent. */ + public function refund(User $user, int $amountMinor, string $reference, string $description): void + { + if ($amountMinor <= 0 || ! $this->isConfigured()) { + return; + } + + try { + $this->billing->credit($user->public_id, $amountMinor, $this->service(), 'mailbox_refund', $reference.':refund', null, $description); + } catch (\Throwable $e) { + Log::error('WalletPaymentService: refund failed', ['reference' => $reference, 'error' => $e->getMessage()]); + } + } +} diff --git a/app/Services/ResellerClub/RcOrderCartService.php b/app/Services/ResellerClub/RcOrderCartService.php new file mode 100644 index 0000000..0d37869 --- /dev/null +++ b/app/Services/ResellerClub/RcOrderCartService.php @@ -0,0 +1,587 @@ + + */ + public function cartStatuses(): array + { + return [ + RcServiceOrder::STATUS_CART, + RcServiceOrder::STATUS_PENDING_PAYMENT, + ]; + } + + public function cartItemsForUser(User $user): EloquentCollection + { + return RcServiceOrder::query() + ->where('user_id', $user->id) + ->whereIn('status', $this->cartStatuses()) + ->latest() + ->get(); + } + + public function cartCountForUser(User $user): int + { + return RcServiceOrder::query() + ->where('user_id', $user->id) + ->whereIn('status', $this->cartStatuses()) + ->count(); + } + + /** + * @return array{items: EloquentCollection, count: int, reference: string|null}|null + */ + public function pendingCheckoutForUser(User $user): ?array + { + $items = RcServiceOrder::query() + ->where('user_id', $user->id) + ->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT) + ->latest() + ->get(); + + if ($items->isEmpty()) { + return null; + } + + return [ + 'items' => $items, + 'count' => $items->count(), + 'reference' => $items->pluck('payment_reference')->filter()->first(), + ]; + } + + public function recentOrdersForUser(User $user, int $limit = 10): EloquentCollection + { + return RcServiceOrder::query() + ->where('user_id', $user->id) + ->whereNotIn('status', $this->cartStatuses()) + ->latest() + ->limit($limit) + ->get(); + } + + public function addProductOrder(User $user, string $category, array $input, ResellerClubProductService $products): RcServiceOrder + { + ResellerClubLegacy::assertNewSaleAllowed(); + + $quote = $products->quoteForSelection( + $category, + (string) ($input['plan_id'] ?? ''), + (int) ($input['months'] ?? 0) + ); + + if (! ($quote['success'] ?? false)) { + throw new RuntimeException($quote['message'] ?? 'This product cannot be added to the cart right now.'); + } + + $currency = strtoupper((string) ($quote['currency'] ?? 'GHS')); + $this->assertCurrencyCompatibility($user, $currency); + + $domainName = strtolower(trim((string) ($input['domain_name'] ?? $category))); + + return $this->upsertCartItem( + $user, + [ + 'category' => $category, + 'order_type' => RcServiceOrder::TYPE_SERVICE, + 'domain_name' => $domainName, + 'plan_id' => (string) ($input['plan_id'] ?? ''), + 'plan_name' => (string) ($quote['plan_name'] ?? $products->packageLabel($category, (string) ($input['plan_id'] ?? ''))), + 'amount_minor' => (int) ($quote['amount_minor'] ?? 0), + 'currency' => $currency, + 'term_months' => (int) ($input['months'] ?? 1), + 'term_years' => null, + 'meta' => [ + 'order_input' => $input, + 'quote' => $quote, + ], + ] + ); + } + + public function addDomainRegistration(User $user, string $domain, DomainRegistrarService $registrar): RcServiceOrder + { + ResellerClubLegacy::assertNewSaleAllowed(); + + $domainName = strtolower(trim($domain)); + + if (Domain::query()->where('host', $domainName)->exists()) { + throw new RuntimeException('This domain is already registered in our system.'); + } + + $quote = $registrar->quoteDomainRegistration($domainName); + if (! ($quote['success'] ?? false)) { + throw new RuntimeException($quote['message'] ?? 'Domain pricing is unavailable right now.'); + } + + $currency = strtoupper((string) ($quote['currency'] ?? 'GHS')); + $this->assertCurrencyCompatibility($user, $currency); + + return $this->upsertCartItem( + $user, + [ + 'category' => 'domain-registration', + 'order_type' => RcServiceOrder::TYPE_DOMAIN_REGISTRATION, + 'domain_name' => $domainName, + 'plan_id' => null, + 'plan_name' => '1 year registration', + 'amount_minor' => (int) ($quote['amount_minor'] ?? 0), + 'currency' => $currency, + 'term_months' => null, + 'term_years' => 1, + 'meta' => [ + 'quote' => $quote, + ], + ] + ); + } + + public function addDomainTransfer(User $user, string $domain, ?string $authCode, DomainRegistrarService $registrar): RcServiceOrder + { + ResellerClubLegacy::assertNewSaleAllowed(); + + $domainName = strtolower(trim($domain)); + $quote = $registrar->quoteDomainTransfer($domainName); + + if (! ($quote['success'] ?? false)) { + throw new RuntimeException($quote['message'] ?? 'This domain cannot be transferred right now.'); + } + + $currency = strtoupper((string) ($quote['currency'] ?? 'GHS')); + $this->assertCurrencyCompatibility($user, $currency); + + $meta = [ + 'quote' => $quote, + ]; + + $authCode = trim((string) $authCode); + if ($authCode !== '') { + $meta['transfer_auth_code_encrypted'] = Crypt::encryptString($authCode); + } + + return $this->upsertCartItem( + $user, + [ + 'category' => 'domain-transfer', + 'order_type' => RcServiceOrder::TYPE_DOMAIN_TRANSFER, + 'domain_name' => $domainName, + 'plan_id' => null, + 'plan_name' => 'Domain transfer', + 'amount_minor' => (int) ($quote['amount_minor'] ?? 0), + 'currency' => $currency, + 'term_months' => null, + 'term_years' => 1, + 'meta' => $meta, + ] + ); + } + + public function addServiceRenewal( + User $user, + RcServiceOrder $existingOrder, + int $months, + ResellerClubProductService $products + ): RcServiceOrder { + ResellerClubLegacy::assertRenewalsAllowed(); + + if ((int) $existingOrder->user_id !== (int) $user->id) { + throw new RuntimeException('You can only renew your own services.'); + } + + if (! filled($existingOrder->rc_order_id)) { + throw new RuntimeException('This service cannot be renewed online yet. Please contact support.'); + } + + if (! $products->supportsCategory($existingOrder->category)) { + throw new RuntimeException('Renewals are not available for this product type.'); + } + + $months = max(1, min(120, $months)); + $planId = (string) ($existingOrder->plan_id ?? ''); + + $quote = $products->quoteRenewal($existingOrder->category, $planId, $months); + if (! ($quote['success'] ?? false)) { + throw new RuntimeException($quote['message'] ?? 'Renewal pricing is unavailable right now.'); + } + + $currency = strtoupper((string) ($quote['currency'] ?? 'GHS')); + $this->assertCurrencyCompatibility($user, $currency); + + return $this->upsertCartItem( + $user, + [ + 'category' => $existingOrder->category, + 'order_type' => RcServiceOrder::TYPE_RENEWAL, + 'domain_name' => $existingOrder->domain_name, + 'plan_id' => $planId !== '' ? $planId : null, + 'plan_name' => (string) ($quote['plan_name'] ?? $existingOrder->plan_name ?? 'Service renewal'), + 'amount_minor' => (int) ($quote['amount_minor'] ?? 0), + 'currency' => $currency, + 'term_months' => $months, + 'term_years' => null, + 'meta' => [ + 'is_renewal' => true, + 'renew_rc_order_id' => (string) $existingOrder->rc_order_id, + 'renew_source_order_id' => $existingOrder->id, + 'quote' => $quote, + ], + ] + ); + } + + public function addDomainRenewal( + User $user, + string $domain, + int $years, + DomainRegistrarService $registrar + ): RcServiceOrder { + ResellerClubLegacy::assertRenewalsAllowed(); + + $domainName = strtolower(trim($domain)); + $years = max(1, min(10, $years)); + + $quote = $registrar->quoteDomainRenewal($domainName, $years); + if (! ($quote['success'] ?? false)) { + throw new RuntimeException($quote['message'] ?? 'Domain renewal pricing is unavailable right now.'); + } + + $currency = strtoupper((string) ($quote['currency'] ?? 'GHS')); + $this->assertCurrencyCompatibility($user, $currency); + + return $this->upsertCartItem( + $user, + [ + 'category' => 'domain-renewal', + 'order_type' => RcServiceOrder::TYPE_RENEWAL, + 'domain_name' => $domainName, + 'plan_id' => null, + 'plan_name' => $years === 1 ? '1 year renewal' : "{$years} year renewal", + 'amount_minor' => (int) ($quote['amount_minor'] ?? 0), + 'currency' => $currency, + 'term_months' => null, + 'term_years' => $years, + 'meta' => [ + 'is_renewal' => true, + 'renew_rc_order_id' => (string) ($quote['rc_order_id'] ?? ''), + 'quote' => $quote, + ], + ] + ); + } + + /** + * @return array{items: EloquentCollection, total_minor: int, currency: string, subtotal_minor: int, discount_minor: int, applied_promos: array, promo_name: string|null} + */ + public function checkoutSummaryForUser(User $user): array + { + $items = $this->cartItemsForUser($user); + ResellerClubLegacy::assertCartCheckoutAllowed($items); + + $currency = strtoupper((string) $items->first()->currency); + + if ($items->contains(fn (RcServiceOrder $item) => strtoupper((string) $item->currency) !== $currency)) { + throw new RuntimeException('Your cart contains items in more than one currency. Clear it and try again with a single currency.'); + } + + $subtotalMinor = (int) $items->sum('amount_minor'); + + // Get all active promos with discounts + $activePromos = PromoBanner::active() + ->whereNotNull('discount_percent') + ->where('discount_percent', '>', 0) + ->get(); + + // Calculate per-item discounts based on product-specific promos + $appliedPromos = []; + $totalDiscountMinor = 0; + + foreach ($items as $item) { + $itemPromo = $this->findMatchingPromoForItem($item, $activePromos); + + if ($itemPromo) { + $discountPercent = (int) $itemPromo->discount_percent; + $itemDiscount = (int) round($item->amount_minor * ($discountPercent / 100)); + $totalDiscountMinor += $itemDiscount; + + // Track applied promos for display + $promoKey = $itemPromo->id; + if (!isset($appliedPromos[$promoKey])) { + $appliedPromos[$promoKey] = [ + 'id' => $itemPromo->id, + 'name' => $itemPromo->name, + 'title' => $itemPromo->title ?: $itemPromo->name, + 'product_type' => $itemPromo->product_type, + 'discount_percent' => $discountPercent, + 'discount_minor' => 0, + 'items' => [], + ]; + } + $appliedPromos[$promoKey]['discount_minor'] += $itemDiscount; + $appliedPromos[$promoKey]['items'][] = [ + 'id' => $item->id, + 'domain_name' => $item->domain_name, + 'category' => $item->category, + 'original_amount' => $item->amount_minor, + 'discount_amount' => $itemDiscount, + ]; + } + } + + $totalMinor = max(0, $subtotalMinor - $totalDiscountMinor); + + // For backward compatibility, provide a single promo name if only one promo applied + $promoName = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['name'] : null; + $discountPercent = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['discount_percent'] : null; + + return [ + 'items' => $items, + 'currency' => $currency, + 'subtotal_minor' => $subtotalMinor, + 'discount_minor' => $totalDiscountMinor, + 'discount_percent' => $discountPercent, + 'promo_name' => $promoName, + 'applied_promos' => array_values($appliedPromos), + 'total_minor' => $totalMinor, + ]; + } + + /** + * Find the best matching promo for a cart item based on product type. + */ + private function findMatchingPromoForItem(RcServiceOrder $item, $promos): ?PromoBanner + { + $itemProductType = $this->mapItemToProductType($item); + + // First, try to find a product-specific promo + $specificPromo = $promos->first(function (PromoBanner $promo) use ($itemProductType) { + return $promo->product_type && $promo->product_type === $itemProductType; + }); + + if ($specificPromo) { + return $specificPromo; + } + + // Fall back to general promos (no product_type set or product_type is 'general') + return $promos->first(function (PromoBanner $promo) { + return !$promo->product_type || $promo->product_type === PromoBanner::TYPE_GENERAL; + }); + } + + /** + * Map a cart item to a promo product type. + */ + private function mapItemToProductType(RcServiceOrder $item): string + { + // Domain orders + if (in_array($item->order_type, [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER, RcServiceOrder::TYPE_RENEWAL], true) + && str_contains((string) $item->category, 'domain')) { + return PromoBanner::TYPE_DOMAINS; + } + + // Map category to product type + $categoryMap = [ + 'domain-registration' => PromoBanner::TYPE_DOMAINS, + 'domain-transfer' => PromoBanner::TYPE_DOMAINS, + 'single-domain-hosting' => PromoBanner::TYPE_HOSTING, + 'multi-domain-hosting' => PromoBanner::TYPE_HOSTING, + 'reseller-hosting' => PromoBanner::TYPE_HOSTING, + 'cloud-hosting' => PromoBanner::TYPE_HOSTING, + 'vps' => PromoBanner::TYPE_VPS, + 'dedicated-server' => PromoBanner::TYPE_DEDICATED, + 'email' => PromoBanner::TYPE_EMAIL, + ]; + + return $categoryMap[$item->category] ?? PromoBanner::TYPE_GENERAL; + } + + /** + * @param Collection|EloquentCollection $items + */ + public function markCheckoutPending(iterable $items, string $reference): void + { + foreach ($items as $item) { + $item->forceFill([ + 'status' => RcServiceOrder::STATUS_PENDING_PAYMENT, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PENDING, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_PENDING, + 'payment_reference' => $reference, + ])->save(); + } + } + + /** + * @return EloquentCollection + */ + public function markPaymentSuccessful(array $metadata, string $reference, ?Carbon $paidAt = null): EloquentCollection + { + $orders = $this->ordersForPaymentMetadata($metadata, $reference); + $transitioned = new EloquentCollection(); + + foreach ($orders as $order) { + if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) { + continue; + } + + $order->forceFill([ + 'status' => RcServiceOrder::STATUS_PROCESSING, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_RECEIVED, + 'payment_reference' => $reference, + 'paid_at' => $paidAt ?: now(), + ])->save(); + + $transitioned->push($order->fresh()); + } + + return $transitioned; + } + + public function markPaymentFailed(array $metadata, string $reference): void + { + $orders = $this->ordersForPaymentMetadata($metadata, $reference); + + foreach ($orders as $order) { + if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) { + continue; + } + + $order->forceFill([ + 'status' => RcServiceOrder::STATUS_CART, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_FAILED, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART, + 'payment_reference' => null, + ])->save(); + } + } + + public function returnPendingCheckoutToCart(User $user): int + { + return RcServiceOrder::query() + ->where('user_id', $user->id) + ->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT) + ->update([ + 'status' => RcServiceOrder::STATUS_CART, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART, + 'payment_reference' => null, + ]); + } + + public function removeCartItem(User $user, RcServiceOrder $order): void + { + if ((int) $order->user_id !== (int) $user->id || ! in_array($order->status, $this->cartStatuses(), true)) { + throw new RuntimeException('This cart item cannot be removed.'); + } + + $order->delete(); + } + + public function clearCart(User $user): int + { + return RcServiceOrder::query() + ->where('user_id', $user->id) + ->whereIn('status', $this->cartStatuses()) + ->delete(); + } + + /** + * @param array $attributes + */ + private function upsertCartItem(User $user, array $attributes): RcServiceOrder + { + $query = RcServiceOrder::query() + ->where('user_id', $user->id) + ->where('order_type', (string) $attributes['order_type']) + ->where('category', (string) $attributes['category']) + ->where('domain_name', (string) $attributes['domain_name']) + ->whereIn('status', $this->cartStatuses()); + + if (isset($attributes['plan_id']) && $attributes['plan_id'] !== null) { + $query->where('plan_id', (string) $attributes['plan_id']); + } else { + $query->whereNull('plan_id'); + } + + if (isset($attributes['term_months']) && $attributes['term_months'] !== null) { + $query->where('term_months', (int) $attributes['term_months']); + } else { + $query->whereNull('term_months'); + } + + $existing = $query->latest('id')->first(); + + $payload = array_merge($attributes, [ + 'user_id' => $user->id, + 'status' => RcServiceOrder::STATUS_CART, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART, + 'payment_reference' => null, + 'paid_at' => null, + 'submitted_at' => null, + 'last_synced_at' => null, + ]); + + if ($existing) { + $existing->forceFill($payload)->save(); + + return $existing->fresh(); + } + + return RcServiceOrder::query()->create($payload); + } + + /** + * @param array $metadata + * @return EloquentCollection + */ + private function ordersForPaymentMetadata(array $metadata, string $reference): EloquentCollection + { + $ids = collect($metadata['rc_service_order_ids'] ?? []) + ->map(fn ($value) => (int) $value) + ->filter() + ->values(); + + if ($ids->isNotEmpty()) { + return RcServiceOrder::query() + ->whereIn('id', $ids->all()) + ->get(); + } + + return RcServiceOrder::query() + ->where('payment_reference', $reference) + ->get(); + } + + private function assertCurrencyCompatibility(User $user, string $currency): void + { + $cartCurrency = RcServiceOrder::query() + ->where('user_id', $user->id) + ->whereIn('status', $this->cartStatuses()) + ->value('currency'); + + if ($cartCurrency && strtoupper((string) $cartCurrency) !== strtoupper($currency)) { + throw new RuntimeException(sprintf( + 'Your cart already contains %s items. Complete or clear that checkout before adding a %s item.', + strtoupper((string) $cartCurrency), + strtoupper($currency) + )); + } + } +} diff --git a/app/Services/ResellerClub/RcOrderFulfillmentService.php b/app/Services/ResellerClub/RcOrderFulfillmentService.php new file mode 100644 index 0000000..1222902 --- /dev/null +++ b/app/Services/ResellerClub/RcOrderFulfillmentService.php @@ -0,0 +1,275 @@ +fresh(['user']) + : RcServiceOrder::query()->with('user')->find($order); + + if (! $order || $order->payment_status !== RcServiceOrder::PAYMENT_STATUS_PAID) { + return; + } + + if (in_array($order->fulfillment_status, [RcServiceOrder::FULFILLMENT_SUBMITTING, RcServiceOrder::FULFILLMENT_FULFILLED], true)) { + return; + } + + if (! ResellerClubLegacy::canAutomateFulfillment($order)) { + $message = ResellerClubLegacy::isRenewalRcServiceOrder($order) + ? ResellerClubLegacy::renewalsDisabledMessage() + : ResellerClubLegacy::fulfillmentDisabledMessage(); + $this->markManualReview($order, $message); + + return; + } + + $order->forceFill([ + 'status' => RcServiceOrder::STATUS_PROCESSING, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_SUBMITTING, + ])->save(); + + $resolved = $this->customers->resolveCustomerForUser($order->user); + if (! ($resolved['success'] ?? false)) { + $this->markManualReview($order, $resolved['message'] ?? 'Could not prepare the ResellerClub customer.'); + + return; + } + + $customerId = (string) ($resolved['customer_id'] ?? ''); + + match ($order->order_type) { + RcServiceOrder::TYPE_DOMAIN_REGISTRATION => $this->submitDomainRegistration($order, $customerId), + RcServiceOrder::TYPE_DOMAIN_TRANSFER => $this->submitDomainTransfer($order, $customerId), + RcServiceOrder::TYPE_RENEWAL => $this->submitRenewal($order, $customerId), + default => $this->submitServiceOrder($order, $customerId), + }; + } + + private function submitDomainRegistration(RcServiceOrder $order, string $customerId): void + { + $contact = $this->registrar->resolveContactForCustomer($order->user, $customerId); + if (! ($contact['success'] ?? false)) { + $this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.'); + + return; + } + + $registration = $this->registrar->registerDomain( + $order->domain_name, + max(1, (int) ($order->term_years ?: 1)), + $customerId, + (string) ($contact['contact_id'] ?? '') + ); + + if (! ($registration['success'] ?? false)) { + $this->markManualReview($order, $registration['message'] ?? 'The registrar did not accept the domain order.'); + + return; + } + + $this->markAwaitingVendor($order, $customerId, (string) ($registration['order_id'] ?? '')); + } + + private function submitDomainTransfer(RcServiceOrder $order, string $customerId): void + { + $contact = $this->registrar->resolveContactForCustomer($order->user, $customerId); + if (! ($contact['success'] ?? false)) { + $this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.'); + + return; + } + + $encryptedAuthCode = (string) Arr::get($order->meta ?? [], 'transfer_auth_code_encrypted', ''); + $authCode = $encryptedAuthCode !== '' ? Crypt::decryptString($encryptedAuthCode) : null; + + $transfer = $this->registrar->transferDomain( + $order->domain_name, + $authCode, + $customerId, + (string) ($contact['contact_id'] ?? '') + ); + + if (! ($transfer['success'] ?? false)) { + $this->markManualReview($order, $transfer['message'] ?? 'The registrar did not accept the transfer request.'); + + return; + } + + $this->markAwaitingVendor($order, $customerId, (string) ($transfer['order_id'] ?? '')); + } + + private function submitRenewal(RcServiceOrder $order, string $customerId): void + { + $rcOrderId = (string) data_get($order->meta, 'renew_rc_order_id', $order->rc_order_id ?? ''); + + if ($rcOrderId === '') { + $this->markManualReview($order, 'Renewal is missing the upstream ResellerClub order id.'); + + return; + } + + if (in_array($order->category, ['domain-renewal', 'domain-registration', 'domain-transfer'], true) + && $order->order_type === RcServiceOrder::TYPE_RENEWAL) { + $years = max(1, (int) ($order->term_years ?: 1)); + $renewal = $this->registrar->renewDomain( + $order->domain_name, + $years, + $customerId + ); + + if (! ($renewal['success'] ?? false)) { + $this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the domain renewal.'); + + return; + } + + $this->markRenewalComplete($order, $customerId, (string) ($renewal['order_id'] ?? $rcOrderId)); + + return; + } + + $months = max(1, (int) ($order->term_months ?: 1)); + $renewal = $this->products->renewOrder($order->category, $rcOrderId, $months); + + if (! ($renewal['success'] ?? false)) { + $this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the renewal.'); + + return; + } + + $this->markRenewalComplete($order, $customerId, $rcOrderId); + } + + private function markRenewalComplete(RcServiceOrder $order, string $customerId, string $rcOrderId): void + { + $details = $this->products->getOrderDetails($order->category === 'domain-renewal' ? 'domain-registration' : $order->category, $rcOrderId); + + if ($details['success'] ?? false) { + $synced = $this->products->syncOrderToLocal( + $order->category === 'domain-renewal' ? 'domain-registration' : $order->category, + (array) ($details['order'] ?? []), + $order->user_id, + $customerId, + $order + ); + + $synced->forceFill([ + 'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE + ? RcServiceOrder::STATUS_ACTIVE + : RcServiceOrder::STATUS_PROCESSING, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED, + 'rc_order_id' => $rcOrderId, + 'submitted_at' => $synced->submitted_at ?: now(), + 'last_synced_at' => now(), + ])->save(); + + return; + } + + $order->forceFill([ + 'rc_customer_id' => $customerId, + 'rc_order_id' => $rcOrderId, + 'status' => RcServiceOrder::STATUS_ACTIVE, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED, + 'submitted_at' => $order->submitted_at ?: now(), + 'last_synced_at' => now(), + ])->save(); + } + + private function submitServiceOrder(RcServiceOrder $order, string $customerId): void + { + $input = (array) Arr::get($order->meta ?? [], 'order_input', []); + $input['domain_name'] = $input['domain_name'] ?? $order->domain_name; + $input['plan_id'] = $input['plan_id'] ?? $order->plan_id; + $input['months'] = $input['months'] ?? $order->term_months ?? 1; + + $result = $this->products->order($order->category, $customerId, $input); + if (! ($result['success'] ?? false)) { + $this->markManualReview($order, $result['message'] ?? 'The provisioning request could not be submitted.'); + + return; + } + + $orderId = (string) ($result['order_id'] ?? ''); + if ($orderId === '') { + $this->markManualReview($order, 'The provisioning request did not return an upstream order id.'); + + return; + } + + $details = $this->products->getOrderDetails($order->category, $orderId); + if ($details['success'] ?? false) { + $synced = $this->products->syncOrderToLocal( + $order->category, + (array) ($details['order'] ?? []), + $order->user_id, + $customerId, + $order + ); + + $synced->forceFill([ + 'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE + ? RcServiceOrder::STATUS_ACTIVE + : RcServiceOrder::STATUS_PROCESSING, + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID, + 'fulfillment_status' => $synced->status === RcServiceOrder::STATUS_ACTIVE + ? RcServiceOrder::FULFILLMENT_FULFILLED + : RcServiceOrder::FULFILLMENT_AWAITING_VENDOR, + 'submitted_at' => $synced->submitted_at ?: now(), + 'last_synced_at' => now(), + ])->save(); + + return; + } + + $this->markAwaitingVendor($order, $customerId, $orderId, $details['message'] ?? null); + } + + private function markAwaitingVendor(RcServiceOrder $order, string $customerId, string $orderId, ?string $message = null): void + { + $meta = (array) ($order->meta ?? []); + if ($message !== null && $message !== '') { + $meta['last_message'] = $message; + } + + $order->forceFill([ + 'rc_customer_id' => $customerId, + 'rc_order_id' => $orderId !== '' ? $orderId : $order->rc_order_id, + 'status' => RcServiceOrder::STATUS_PROCESSING, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_AWAITING_VENDOR, + 'submitted_at' => $order->submitted_at ?: now(), + 'last_synced_at' => now(), + 'meta' => $meta, + ])->save(); + } + + private function markManualReview(RcServiceOrder $order, string $message): void + { + $meta = (array) ($order->meta ?? []); + $meta['last_message'] = $message; + + $order->forceFill([ + 'status' => RcServiceOrder::STATUS_PROCESSING, + 'fulfillment_status' => RcServiceOrder::FULFILLMENT_MANUAL_REVIEW, + 'meta' => $meta, + ])->save(); + } +} diff --git a/app/Services/ResellerClub/ResellerClubConnectivityDiagnosticsService.php b/app/Services/ResellerClub/ResellerClubConnectivityDiagnosticsService.php new file mode 100644 index 0000000..dc6afc6 --- /dev/null +++ b/app/Services/ResellerClub/ResellerClubConnectivityDiagnosticsService.php @@ -0,0 +1,328 @@ +apiUrl = rtrim($value !== '' ? $value : self::DEFAULT_API_URL, '/'); + $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); + $this->apiKey = (string) config('mailinfra.registrar_api_key'); + } + + /** + * @return array{ + * configured: bool, + * api_url: string, + * email: string, + * initial_customer_id: string, + * resolved_customer_id: string, + * probes: array, + * http_status: string, + * summary: string + * }> + * } + */ + public function run(string $email, ?string $customerId, string $ipAddress): array + { + $email = strtolower(trim($email)); + $customerId = trim((string) $customerId); + $probes = []; + + if ($this->authUserId === '' || $this->apiKey === '') { + $probes[] = $this->probeResult( + 'Configuration', + 'error', + 'LOCAL', + 'configuration', + [], + null, + 'ResellerClub credentials are missing.' + ); + + return [ + 'configured' => false, + 'api_url' => $this->apiUrl, + 'email' => $email, + 'initial_customer_id' => $customerId, + 'resolved_customer_id' => '', + 'probes' => $probes, + ]; + } + + $probes[] = $this->probeResult( + 'Configuration', + 'success', + 'LOCAL', + 'configuration', + ['api-url' => $this->apiUrl, 'auth-userid' => $this->maskValue($this->authUserId)], + null, + 'ResellerClub credentials are configured.' + ); + + $resolvedCustomerId = $customerId; + + if ($email !== '') { + $lookup = $this->probeLookup($email); + $probes[] = $lookup['probe']; + + if (($lookup['customer_id'] ?? '') !== '') { + $resolvedCustomerId = (string) $lookup['customer_id']; + } + } + + if ($resolvedCustomerId !== '') { + $probes[] = $this->probeGenerateToken($resolvedCustomerId, $ipAddress); + } + + return [ + 'configured' => true, + 'api_url' => $this->apiUrl, + 'email' => $email, + 'initial_customer_id' => $customerId, + 'resolved_customer_id' => $resolvedCustomerId, + 'probes' => $probes, + ]; + } + + /** + * @return array{ + * probe: array{ + * label: string, + * status: string, + * method: string, + * endpoint: string, + * request: array, + * http_status: string, + * summary: string + * }, + * customer_id?: string + * } + */ + private function probeLookup(string $email): array + { + $endpoint = $this->apiUrl.'/customers/details.json'; + $request = [ + 'auth-userid' => $this->maskValue($this->authUserId), + 'username' => $email, + ]; + + try { + $response = Http::timeout(15)->get($endpoint, [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'username' => $email, + ]); + + $data = $response->json(); + $summary = $this->summarizeResponse($data, (string) $response->body()); + $customerId = is_array($data) ? (string) ($data['customerid'] ?? $data['customer-id'] ?? '') : ''; + + return [ + 'probe' => $this->probeResult( + 'Customer lookup', + $response->successful() ? 'success' : 'error', + 'GET', + $endpoint, + $request, + $response->status(), + $summary !== '' ? $summary : 'No response summary returned.' + ), + 'customer_id' => $customerId, + ]; + } catch (\Throwable $e) { + Log::warning('ResellerClub connectivity diagnostics: customer lookup failed', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + return [ + 'probe' => $this->probeResult( + 'Customer lookup', + 'error', + 'GET', + $endpoint, + $request, + null, + $this->sanitizeText($e->getMessage()) + ), + ]; + } + } + + /** + * @return array{ + * label: string, + * status: string, + * method: string, + * endpoint: string, + * request: array, + * http_status: string, + * summary: string + * } + */ + private function probeGenerateToken(string $customerId, string $ipAddress): array + { + $endpoint = $this->apiUrl.'/customers/generate-login-token.json'; + $request = [ + 'auth-userid' => $this->maskValue($this->authUserId), + 'customer-id' => $customerId, + 'ip' => $ipAddress, + ]; + + try { + $response = Http::timeout(15)->get($endpoint, [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'customer-id' => $customerId, + 'ip' => $ipAddress, + ]); + + $data = $response->json(); + $summary = $this->summarizeResponse($data, (string) $response->body()); + + return $this->probeResult( + 'Generate login token', + $response->successful() ? 'success' : 'error', + 'GET', + $endpoint, + $request, + $response->status(), + $summary !== '' ? $summary : 'No response summary returned.' + ); + } catch (\Throwable $e) { + Log::warning('ResellerClub connectivity diagnostics: token generation failed', [ + 'customer_id' => $customerId, + 'error' => $e->getMessage(), + ]); + + return $this->probeResult( + 'Generate login token', + 'error', + 'GET', + $endpoint, + $request, + null, + $this->sanitizeText($e->getMessage()) + ); + } + } + + /** + * @param array|mixed $data + */ + private function summarizeResponse(mixed $data, string $body): string + { + if (is_array($data)) { + foreach (['message', 'error', 'description', 'msg'] as $key) { + $value = $this->sanitizeText((string) ($data[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + if (($data['customerid'] ?? null) !== null) { + return 'Customer found: '.(string) $data['customerid']; + } + + if (($data['token'] ?? null) !== null || ($data['loginid'] ?? null) !== null || ($data['userLoginId'] ?? null) !== null) { + return 'Login token generated successfully.'; + } + } + + return $this->sanitizeText($body); + } + + private function sanitizeText(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if ($this->looksLikeHtmlDocument($value)) { + return 'HTML error page from upstream. ResellerClub temporarily blocked or rejected the request.'; + } + + $plain = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); + + return mb_strimwidth($plain, 0, 240, '...'); + } + + private function looksLikeHtmlDocument(string $value): bool + { + $normalized = strtolower(ltrim($value)); + + return str_starts_with($normalized, ' $request + * @return array{ + * label: string, + * status: string, + * method: string, + * endpoint: string, + * request: array, + * http_status: string, + * summary: string + * } + */ + private function probeResult( + string $label, + string $status, + string $method, + string $endpoint, + array $request, + ?int $httpStatus, + string $summary + ): array { + return [ + 'label' => $label, + 'status' => $status, + 'method' => $method, + 'endpoint' => $endpoint, + 'request' => $request, + 'http_status' => $httpStatus !== null ? (string) $httpStatus : 'n/a', + 'summary' => $summary, + ]; + } + + private function maskValue(string $value): string + { + $value = trim($value); + + if ($value === '') { + return 'missing'; + } + + if (strlen($value) <= 4) { + return str_repeat('*', strlen($value)); + } + + return substr($value, 0, 2).str_repeat('*', max(strlen($value) - 4, 2)).substr($value, -2); + } +} diff --git a/app/Services/ResellerClub/ResellerClubConsoleService.php b/app/Services/ResellerClub/ResellerClubConsoleService.php new file mode 100644 index 0000000..ca1180f --- /dev/null +++ b/app/Services/ResellerClub/ResellerClubConsoleService.php @@ -0,0 +1,50 @@ +configuredControlPanelUrl(), '/').'/servlet/ManageServiceServletForAPI'; + } + + /** + * @return array + */ + public function buildManagedServicePayload(string $loginToken, string $orderId, string $serviceName): array + { + return [ + 'loginid' => $loginToken, + 'orderid' => $orderId, + 'service-name' => $serviceName, + ]; + } + + public function autoLoginUrl(string $loginToken, string $role = 'customer'): string + { + return sprintf( + '%s/servlet/AutoLoginServlet?userLoginId=%s&role=%s', + rtrim($this->configuredControlPanelUrl(), '/'), + urlencode($loginToken), + urlencode($role) + ); + } + + private function configuredControlPanelUrl(): string + { + $value = trim((string) config('mailinfra.resellerclub_control_panel_url')); + + return $value !== '' ? $value : self::DEFAULT_CONTROL_PANEL_URL; + } +} diff --git a/app/Services/ResellerClub/ResellerClubCustomerService.php b/app/Services/ResellerClub/ResellerClubCustomerService.php new file mode 100644 index 0000000..2677b86 --- /dev/null +++ b/app/Services/ResellerClub/ResellerClubCustomerService.php @@ -0,0 +1,655 @@ +apiUrl = rtrim($this->configuredApiUrl(), '/'); + $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); + $this->apiKey = (string) config('mailinfra.registrar_api_key'); + } + + public function isConfigured(): bool + { + return $this->authUserId !== '' && $this->apiKey !== ''; + } + + /** + * Build a URL with all parameters as query string values, including auth. + * + * @param array $params + */ + private function buildQueryUrl(string $endpoint, array $params = []): string + { + $all = array_merge([ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ], $params); + + $separator = str_contains($endpoint, '?') ? '&' : '?'; + + return $endpoint.$separator.http_build_query($all); + } + + /** + * @return array{success: bool, customer_id?: string, created?: bool, message?: string} + */ + public function resolveCustomerForUser(User $user): array + { + if (! $this->isConfigured()) { + return ['success' => false, 'message' => 'ResellerClub is not configured.']; + } + + if ($user->hasResellerClubCustomer()) { + return [ + 'success' => true, + 'customer_id' => (string) $user->rc_customer_id, + 'created' => false, + ]; + } + + $email = strtolower(trim((string) $user->email)); + if ($email === '') { + return ['success' => false, 'message' => 'User email is required to provision a ResellerClub customer.']; + } + + $lookup = $this->findCustomerByEmail($email); + if (! ($lookup['success'] ?? false)) { + return ['success' => false, 'message' => $lookup['message'] ?? 'Could not verify the ResellerClub customer.']; + } + + if ($lookup['found'] ?? false) { + return $this->attachCustomerToUser( + $user, + (string) $lookup['customer_id'], + $email, + false + ); + } + + $created = $this->createCustomerForUser($user); + if (! ($created['success'] ?? false)) { + return ['success' => false, 'message' => $created['message'] ?? 'Could not create the ResellerClub customer.']; + } + + $attached = $this->attachCustomerToUser( + $user, + (string) $created['customer_id'], + $email, + false + ); + + if (! ($attached['success'] ?? false)) { + return $attached; + } + + return [ + 'success' => true, + 'customer_id' => (string) $attached['customer_id'], + 'created' => true, + ]; + } + + /** + * Best-effort customer provisioning for user creation hooks. + */ + public function provisionCustomerForUser(User $user): ?string + { + $result = $this->resolveCustomerForUser($user); + + if (! ($result['success'] ?? false)) { + Log::warning('ResellerClubCustomerService: automatic provisioning skipped', [ + 'user_id' => $user->id, + 'message' => $result['message'] ?? 'Unknown error.', + ]); + + return null; + } + + return (string) ($result['customer_id'] ?? ''); + } + + /** + * @return array{success: bool, customer_id?: string, found?: bool, message?: string} + */ + public function findCustomerByEmail(string $email): array + { + try { + $response = Http::timeout(15)->get($this->apiUrl.'/customers/details.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'username' => strtolower(trim($email)), + ]); + + $data = $response->json(); + + if ($response->successful() && is_array($data)) { + $customerId = (string) ($data['customerid'] ?? $data['customer-id'] ?? ''); + + if ($customerId !== '') { + return [ + 'success' => true, + 'found' => true, + 'customer_id' => $customerId, + ]; + } + } + + if (in_array($response->status(), [400, 404], true)) { + return [ + 'success' => true, + 'found' => false, + ]; + } + + $message = $this->extractErrorMessage(is_array($data) ? $data : [], $response->body()); + + if ($message !== '' && $this->looksLikeMissingCustomerMessage($message)) { + return [ + 'success' => true, + 'found' => false, + ]; + } + + Log::warning('ResellerClubCustomerService: customer lookup failed', [ + 'email' => $email, + 'status' => $response->status(), + 'response' => $data ?: $response->body(), + ]); + + return [ + 'success' => false, + 'message' => $message !== '' ? $message : 'Could not look up the ResellerClub customer.', + ]; + } catch (\Throwable $e) { + Log::error('ResellerClubCustomerService: findCustomerByEmail failed', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not look up the ResellerClub customer.']; + } + } + + /** + * @return array{success: bool, customer_id?: string, message?: string} + */ + public function createCustomerForUser(User $user): array + { + $defaults = (array) config('mailinfra.resellerclub_customer_defaults', []); + $email = strtolower(trim((string) $user->email)); + $name = trim(implode(' ', array_filter([ + trim((string) ($user->first_name ?? '')), + trim((string) ($user->last_name ?? '')), + ]))); + + try { + $response = Http::timeout(30)->post($this->buildQueryUrl($this->apiUrl.'/customers/v2/signup.xml', [ + 'username' => $email, + 'passwd' => $this->generatePlatformPassword(), + 'name' => $name !== '' ? $name : (trim((string) $user->name) !== '' ? (string) $user->name : $email), + 'company' => trim((string) ($user->company ?? '')) !== '' ? (string) $user->company : (string) ($defaults['company'] ?? config('app.name', 'Ladill')), + 'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== '' ? (string) $user->address_line_1 : (string) ($defaults['address_line_1'] ?? 'Not provided'), + 'city' => trim((string) ($user->city ?? '')) !== '' ? (string) $user->city : (string) ($defaults['city'] ?? 'Accra'), + 'state' => trim((string) ($user->state ?? '')) !== '' ? (string) $user->state : (string) ($defaults['state'] ?? 'Not Applicable'), + 'country' => trim((string) ($user->country ?? '')) !== '' ? strtoupper((string) $user->country) : (string) ($defaults['country'] ?? 'GH'), + 'zipcode' => trim((string) ($user->zipcode ?? '')) !== '' ? (string) $user->zipcode : (string) ($defaults['zipcode'] ?? '00000'), + 'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== '' ? ltrim((string) $user->phone_cc, '+') : (string) ($defaults['phone_cc'] ?? '233'), + 'phone' => trim((string) ($user->phone ?? '')) !== '' ? (string) $user->phone : (string) ($defaults['phone'] ?? '000000000'), + 'lang-pref' => (string) ($defaults['lang_pref'] ?? 'en'), + 'address-line-2' => trim((string) ($user->address_line_2 ?? '')) !== '' ? (string) $user->address_line_2 : (string) ($defaults['address_line_2'] ?? ''), + 'address-line-3' => (string) ($defaults['address_line_3'] ?? ''), + ])); + + $body = trim((string) $response->body()); + $customerId = $this->extractCustomerIdFromBody($body); + + if ($response->failed() || $customerId === '') { + $message = $this->extractXmlErrorMessage($body); + + Log::warning('ResellerClubCustomerService: customer signup failed', [ + 'user_id' => $user->id, + 'email' => $email, + 'status' => $response->status(), + 'body' => $body, + ]); + + return [ + 'success' => false, + 'message' => $message !== '' ? $message : 'Could not create the ResellerClub customer.', + ]; + } + + return [ + 'success' => true, + 'customer_id' => $customerId, + ]; + } catch (\Throwable $e) { + Log::error('ResellerClubCustomerService: createCustomerForUser failed', [ + 'user_id' => $user->id, + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not create the ResellerClub customer.']; + } + } + + /** + * @return array{success: bool, customer_id?: string, message?: string} + */ + public function attachCustomerToUser(User $user, string $customerId, ?string $email = null, bool $markLinked = false): array + { + $customerId = trim($customerId); + + if ($customerId === '') { + return ['success' => false, 'message' => 'A valid ResellerClub customer ID is required.']; + } + + $existing = User::query() + ->where('rc_customer_id', $customerId) + ->where('id', '!=', $user->id) + ->first(); + + if ($existing) { + return ['success' => false, 'message' => 'This ResellerClub account is already linked to another Ladill account.']; + } + + $payload = [ + 'rc_customer_id' => $customerId, + 'rc_email' => $email ?: $user->rc_email ?: $user->email, + ]; + + if ($markLinked) { + $payload['rc_linked_at'] = now(); + } + + $user->forceFill($payload)->save(); + + return [ + 'success' => true, + 'customer_id' => $customerId, + ]; + } + + /** + * @return array{success: bool, token?: string, message?: string} + */ + public function generateLoginToken(User $user, string $ipAddress): array + { + if (! $user->hasResellerClubCustomer()) { + return ['success' => false, 'message' => 'ResellerClub customer is not set for this user.']; + } + + try { + $response = Http::timeout(15)->get($this->apiUrl.'/customers/generate-login-token.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'customer-id' => (string) $user->rc_customer_id, + 'ip' => $ipAddress, + ]); + + $data = $response->json(); + $rawBody = trim((string) $response->body()); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->extractErrorMessage(is_array($data) ? $data : [], $rawBody) ?: 'Could not create the ResellerClub login token.', + ]; + } + + $token = is_array($data) + ? (string) ($data['token'] ?? $data['loginid'] ?? $data['userLoginId'] ?? '') + : trim($rawBody, "\"'"); + + if ($token === '') { + return ['success' => false, 'message' => 'Could not create the ResellerClub login token.']; + } + + return ['success' => true, 'token' => $token]; + } catch (\Throwable $e) { + Log::error('ResellerClubCustomerService: generateLoginToken failed', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not create the ResellerClub login token.']; + } + } + + public function generatePlatformPassword(): string + { + $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + $lower = 'abcdefghijkmnopqrstuvwxyz'; + $numbers = '23456789'; + $special = '!@$#%_+?'; + $pool = $upper.$lower.$numbers.$special; + + $password = [ + $upper[random_int(0, strlen($upper) - 1)], + $lower[random_int(0, strlen($lower) - 1)], + $numbers[random_int(0, strlen($numbers) - 1)], + $special[random_int(0, strlen($special) - 1)], + ]; + + while (count($password) < 12) { + $password[] = $pool[random_int(0, strlen($pool) - 1)]; + } + + shuffle($password); + + return implode('', $password); + } + + /** + * @return array{ + * success: bool, + * configured: bool, + * api_url: string, + * auth_userid_present: bool, + * current_customer_id: string, + * current_rc_email: string, + * linked: bool, + * steps: array + * } + */ + public function runDiagnostics(User $user, string $ipAddress): array + { + $steps = []; + + if (! $this->isConfigured()) { + $steps[] = $this->diagnosticStep('Configuration', 'error', 'ResellerClub is not configured.'); + + return $this->diagnosticSummary($user, $steps, false); + } + + $steps[] = $this->diagnosticStep('Configuration', 'success', 'ResellerClub credentials are configured.'); + + if ($user->hasResellerClubCustomer()) { + $steps[] = $this->diagnosticStep( + 'Stored customer', + 'success', + 'Current user already has ResellerClub customer ID '.$user->rc_customer_id.'.' + ); + } else { + $email = strtolower(trim((string) $user->email)); + if ($email === '') { + $steps[] = $this->diagnosticStep('Customer lookup', 'error', 'User email is required to provision a ResellerClub customer.'); + + return $this->diagnosticSummary($user, $steps, false); + } + + $lookup = $this->findCustomerByEmail($email); + if (! ($lookup['success'] ?? false)) { + $steps[] = $this->diagnosticStep( + 'Customer lookup', + 'error', + $lookup['message'] ?? 'Could not look up the ResellerClub customer.' + ); + + return $this->diagnosticSummary($user, $steps, false); + } + + if ($lookup['found'] ?? false) { + $customerId = (string) ($lookup['customer_id'] ?? ''); + $steps[] = $this->diagnosticStep( + 'Customer lookup', + 'success', + 'Existing ResellerClub customer found: '.$customerId.'.' + ); + + $attached = $this->attachCustomerToUser($user, $customerId, $email, false); + if (! ($attached['success'] ?? false)) { + $steps[] = $this->diagnosticStep( + 'Attach customer', + 'error', + $attached['message'] ?? 'Could not attach the ResellerClub customer to this Ladill account.' + ); + + return $this->diagnosticSummary($user, $steps, false); + } + + $steps[] = $this->diagnosticStep('Attach customer', 'success', 'Existing ResellerClub customer attached to this Ladill account.'); + $user->refresh(); + } else { + $steps[] = $this->diagnosticStep('Customer lookup', 'warning', 'No existing ResellerClub customer was found for this email.'); + + $created = $this->createCustomerForUser($user); + if (! ($created['success'] ?? false)) { + $steps[] = $this->diagnosticStep( + 'Create customer', + 'error', + $created['message'] ?? 'Could not create the ResellerClub customer.' + ); + + return $this->diagnosticSummary($user, $steps, false); + } + + $customerId = (string) ($created['customer_id'] ?? ''); + $steps[] = $this->diagnosticStep('Create customer', 'success', 'New ResellerClub customer created: '.$customerId.'.'); + + $attached = $this->attachCustomerToUser($user, $customerId, $email, false); + if (! ($attached['success'] ?? false)) { + $steps[] = $this->diagnosticStep( + 'Attach customer', + 'error', + $attached['message'] ?? 'Could not attach the new ResellerClub customer to this Ladill account.' + ); + + return $this->diagnosticSummary($user, $steps, false); + } + + $steps[] = $this->diagnosticStep('Attach customer', 'success', 'New ResellerClub customer attached to this Ladill account.'); + $user->refresh(); + } + } + + $token = $this->generateLoginToken($user, $ipAddress); + if (! ($token['success'] ?? false)) { + $steps[] = $this->diagnosticStep( + 'Generate login token', + 'error', + $token['message'] ?? 'Could not create the ResellerClub login token.' + ); + + return $this->diagnosticSummary($user, $steps, false); + } + + $steps[] = $this->diagnosticStep('Generate login token', 'success', 'ResellerClub login token generated successfully.'); + + return $this->diagnosticSummary($user, $steps, true); + } + + private function configuredApiUrl(): string + { + $value = trim((string) config('mailinfra.registrar_api_url')); + + return $value !== '' ? $value : self::DEFAULT_API_URL; + } + + private function looksLikeMissingCustomerMessage(string $message): bool + { + $value = strtolower(trim($message)); + + return str_contains($value, 'not found') + || str_contains($value, 'does not exist') + || str_contains($value, 'no customer') + || str_contains($value, 'unable to find') + || str_contains($value, 'entity not found') + || str_contains($value, 'no entity found'); + } + + /** + * @param array $data + */ + private function extractErrorMessage(array $data, string $fallbackBody = ''): string + { + foreach (['message', 'error', 'description', 'msg'] as $key) { + $value = $this->sanitizeErrorText((string) ($data[$key] ?? '')); + if ($value !== '') { + return $value; + } + } + + return $this->sanitizeErrorText($fallbackBody); + } + + private function extractCustomerIdFromBody(string $body): string + { + if ($body !== '' && preg_match('/^\d+$/', $body) === 1) { + return $body; + } + + $xml = $this->parseXml($body); + if ($xml === null) { + return ''; + } + + $flattened = strtolower(preg_replace('/\s+/', ' ', strip_tags($body)) ?? ''); + if (str_contains($flattened, 'error')) { + return ''; + } + + foreach (['customerid', 'customer-id', 'entityid', 'entity-id', 'id', 'description'] as $key) { + $value = trim((string) ($xml[$key] ?? '')); + if ($value !== '' && preg_match('/^\d+$/', $value) === 1) { + return $value; + } + } + + if (preg_match('/\b(\d{3,})\b/', trim(strip_tags($body)), $matches) === 1) { + return $matches[1]; + } + + return ''; + } + + private function extractXmlErrorMessage(string $body): string + { + $xml = $this->parseXml($body); + + if ($xml !== null) { + foreach (['message', 'error', 'description', 'msg', 'status'] as $key) { + $value = $this->sanitizeErrorText((string) ($xml[$key] ?? '')); + if ($value !== '' && ! preg_match('/^\d+$/', $value)) { + return $value; + } + } + } + + return $this->sanitizeErrorText($body); + } + + /** + * @return array|null + */ + private function parseXml(string $body): ?array + { + $body = trim($body); + if ($body === '' || ! str_starts_with($body, '<')) { + return null; + } + + $previous = libxml_use_internal_errors(true); + + try { + $xml = simplexml_load_string($body, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET); + if (! $xml instanceof \SimpleXMLElement) { + return null; + } + + $data = json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + + return is_array($data) ? $data : null; + } catch (\Throwable) { + return null; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + private function sanitizeErrorText(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if ($this->looksLikeHtmlDocument($value)) { + return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.'; + } + + $plainText = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); + + return $plainText; + } + + private function looksLikeHtmlDocument(string $value): bool + { + $normalized = strtolower(ltrim($value)); + + return str_starts_with($normalized, '') + || str_contains($normalized, 'cloudflare'); + } + + /** + * @return array{label: string, status: string, message: string} + */ + private function diagnosticStep(string $label, string $status, string $message): array + { + return [ + 'label' => $label, + 'status' => $status, + 'message' => $message, + ]; + } + + /** + * @param array $steps + * @return array{ + * success: bool, + * configured: bool, + * api_url: string, + * auth_userid_present: bool, + * current_customer_id: string, + * current_rc_email: string, + * linked: bool, + * steps: array + * } + */ + private function diagnosticSummary(User $user, array $steps, bool $success): array + { + $user->refresh(); + + return [ + 'success' => $success, + 'configured' => $this->isConfigured(), + 'api_url' => $this->apiUrl, + 'auth_userid_present' => $this->authUserId !== '', + 'current_customer_id' => (string) ($user->rc_customer_id ?? ''), + 'current_rc_email' => (string) ($user->rc_email ?? ''), + 'linked' => $user->hasLinkedResellerClubAccount(), + 'steps' => $steps, + ]; + } +} diff --git a/app/Services/ResellerClub/ResellerClubProductService.php b/app/Services/ResellerClub/ResellerClubProductService.php new file mode 100644 index 0000000..0c6e860 --- /dev/null +++ b/app/Services/ResellerClub/ResellerClubProductService.php @@ -0,0 +1,1249 @@ +apiUrl = rtrim($this->configuredApiUrl(), '/'); + $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); + $this->apiKey = (string) config('mailinfra.registrar_api_key'); + $this->sellingCurrency = strtoupper(trim((string) config('mailinfra.registrar_selling_currency', 'GHS'))); + } + + public function isConfigured(): bool + { + return $this->authUserId !== '' && $this->apiKey !== ''; + } + + public function lastSyncError(): ?string + { + return $this->lastSyncError; + } + + /** + * Build a URL with all parameters as query string values, including auth. + * Handles array values as repeated keys (e.g. addon=a&addon=b) as required by the RC API. + * + * @param array $params + */ + private function buildQueryUrl(string $endpoint, array $params = []): string + { + $all = array_merge([ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ], $params); + + $segments = []; + + foreach ($all as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + if ($item === null || $item === '') { + continue; + } + $segments[] = rawurlencode((string) $key).'='.rawurlencode((string) $item); + } + continue; + } + + if ($value === null || $value === '') { + continue; + } + + $normalized = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; + $segments[] = rawurlencode((string) $key).'='.rawurlencode($normalized); + } + + if ($segments === []) { + return $endpoint; + } + + $separator = str_contains($endpoint, '?') ? '&' : '?'; + + return $endpoint.$separator.implode('&', $segments); + } + + /** + * @return array> + */ + public function definitions(): array + { + return config('mailinfra.resellerclub_products', []); + } + + public function supportsCategory(string $category): bool + { + return isset($this->definitions()[$category]); + } + + /** + * @return array + */ + public function definition(string $category): array + { + return $this->definitions()[$category] ?? []; + } + + public function packageLabel(string $category, string $planId, ?string $fallback = null): string + { + foreach ($this->packagesForCategory($category) as $package) { + if ($package['value'] === $planId) { + return $package['label']; + } + } + + return $fallback ?: 'Standard package'; + } + + /** + * @return array + */ + public function formDefinition(string $category): array + { + $definition = $this->definition($category); + $packages = $this->packagesForCategory($category); + + return [ + 'packages' => $packages, + 'has_packages' => $packages !== [], + 'region' => (string) ($definition['region'] ?? 'US'), + 'requires_domain' => (bool) ($definition['requires_domain'] ?? true), + 'supports_os' => (bool) ($definition['supports_os'] ?? false), + 'os_options' => (array) ($definition['os_options'] ?? []), + 'supports_addons' => (bool) ($definition['supports_addons'] ?? false), + 'addon_options' => (array) ($definition['addon_options'] ?? []), + 'supports_ssl_toggle' => (bool) ($definition['supports_ssl_toggle'] ?? false), + 'supports_dedicated_ip' => (bool) ($definition['supports_dedicated_ip'] ?? false), + ]; + } + + /** + * @return array|string> + */ + public function validationRules(string $category): array + { + $definition = $this->formDefinition($category); + + $rules = [ + 'plan_id' => ['required', 'string', 'max:120'], + 'months' => ['required', 'integer', 'min:1', 'max:120'], + ]; + + if ($definition['requires_domain']) { + $rules['domain_name'] = ['required', 'string', 'max:253']; + } + + if ($definition['supports_os']) { + $rules['os'] = ['nullable', 'string', 'max:120']; + } + + if ($definition['supports_addons']) { + $rules['addons'] = ['nullable', 'array']; + $rules['addons.*'] = ['string', 'max:120']; + } + + if ($definition['supports_ssl_toggle']) { + $rules['enable_ssl'] = ['nullable', 'boolean']; + } + + if ($definition['supports_dedicated_ip']) { + $rules['add_dedicated_ip'] = ['nullable', 'boolean']; + } + + return $rules; + } + + /** + * @param array $input + * @return array{success: bool, order_id?: string, message?: string, raw?: array} + */ + public function order(string $category, string $customerId, array $input): array + { + $definition = $this->definition($category); + + if ($definition === []) { + return ['success' => false, 'message' => 'This product is not available right now.']; + } + + $payload = [ + 'customer-id' => $customerId, + 'months' => (int) ($input['months'] ?? 1), + 'plan-id' => (string) ($input['plan_id'] ?? ''), + 'auto-renew' => 'true', + 'invoice-option' => 'NoInvoice', + ]; + + if (($definition['requires_domain'] ?? true) === true) { + $payload['domain-name'] = strtolower(trim((string) ($input['domain_name'] ?? ''))); + } + + if (($definition['supports_os'] ?? false) && ! empty($input['os'])) { + $payload['os'] = (string) $input['os']; + } + + if (($definition['supports_addons'] ?? false) && ! empty($input['addons'])) { + $payload['addon'] = array_values((array) $input['addons']); + } + + if (($definition['supports_ssl_toggle'] ?? false)) { + $payload['enable-ssl'] = ! empty($input['enable_ssl']) ? 'true' : 'false'; + } + + if (($definition['supports_dedicated_ip'] ?? false) && ! empty($input['add_dedicated_ip'])) { + $payload['add-dedicated-ip'] = 'true'; + } + + $endpoint = $this->endpoint($definition, 'add'); + + try { + $response = Http::timeout(30)->post($this->buildQueryUrl($endpoint, $payload)); + $data = $response->json(); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->responseMessage($response, $data, 'The order could not be placed.'), + ]; + } + + return [ + 'success' => true, + 'order_id' => (string) ($data['entityid'] ?? $data['orderid'] ?? ''), + 'raw' => is_array($data) ? $data : [], + ]; + } catch (\Throwable $e) { + Log::error('ResellerClubProductService: order failed', [ + 'category' => $category, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'The order failed due to a network error.']; + } + } + + /** + * @return array{success: bool, amount_minor?: int, currency?: string, plan_name?: string, message?: string} + */ + public function quoteRenewal(string $category, string $planId, int $months): array + { + $definition = $this->definition($category); + + if ($definition === []) { + return ['success' => false, 'message' => 'This product is not available for renewal.']; + } + + $packages = $this->packagesForCategory($category); + $package = collect($packages)->firstWhere('value', $planId) ?? $packages[0] ?? null; + + if (! is_array($package)) { + return ['success' => false, 'message' => 'Could not determine renewal pricing for this plan.']; + } + + $amountsMinor = (array) ($package['renew_amounts_minor'] ?? $package['amounts_minor'] ?? []); + $amountMinor = (int) ($amountsMinor[(string) $months] ?? $amountsMinor['12'] ?? $amountsMinor['1'] ?? 0); + + if ($amountMinor <= 0) { + return ['success' => false, 'message' => 'Renewal pricing is unavailable for the selected term.']; + } + + return [ + 'success' => true, + 'amount_minor' => $amountMinor, + 'currency' => $this->sellingCurrency, + 'plan_name' => (string) ($package['label'] ?? 'Service renewal'), + ]; + } + + /** + * @return array{success: bool, message?: string} + */ + public function renewOrder(string $category, string $rcOrderId, int $months): array + { + $definition = $this->definition($category); + + if ($definition === []) { + return ['success' => false, 'message' => 'This product is not available for renewal.']; + } + + try { + $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint($definition, 'renew'), [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $rcOrderId, + 'months' => max(1, $months), + 'invoice-option' => 'NoInvoice', + ])); + + $data = $response->json(); + + if ($response->failed()) { + return [ + 'success' => false, + 'message' => $this->responseMessage($response, $data, 'Renewal could not be completed.'), + ]; + } + + return ['success' => true]; + } catch (\Throwable $e) { + Log::error('ResellerClubProductService: renewOrder failed', [ + 'category' => $category, + 'order_id' => $rcOrderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Renewal failed due to a network error.']; + } + } + + /** + * @return array{success: bool, order?: array, message?: string} + */ + public function getOrderDetails(string $category, string $orderId): array + { + $definition = $this->definition($category); + + if ($definition === []) { + return ['success' => false, 'message' => 'Unsupported product category.']; + } + + try { + $response = Http::timeout(20)->get($this->endpoint($definition, 'details'), [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'order-id' => $orderId, + ]); + + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + return [ + 'success' => false, + 'message' => $this->responseMessage($response, $data, 'Could not fetch the latest order details.'), + ]; + } + + return [ + 'success' => true, + 'order' => $this->normalizeOrder($category, $data), + ]; + } catch (\Throwable $e) { + Log::error('ResellerClubProductService: details failed', [ + 'category' => $category, + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return ['success' => false, 'message' => 'Could not fetch the latest order details.']; + } + } + + /** + * Sync supported non-hosting RC service orders for a specific customer. + * + * @return int Number of orders synced. + */ + public function syncCustomerOrders(string $customerId, int $userId): int + { + $this->lastSyncError = null; + + if (! $this->isConfigured()) { + return 0; + } + + $synced = 0; + + foreach (array_keys($this->definitions()) as $category) { + if ($category === 'single-domain-hosting') { + continue; + } + + $page = 1; + $categorySynced = 0; + + do { + $result = $this->searchOrdersByCustomer($category, $customerId, $page, 50); + + if (! ($result['success'] ?? false) || empty($result['orders'])) { + break; + } + + foreach ((array) $result['orders'] as $order) { + $rawOrder = is_array($order) ? $order : []; + $orderId = (string) ($rawOrder['entityid'] ?? $rawOrder['orderid'] ?? $rawOrder['entity-id'] ?? ''); + + if ($orderId !== '') { + $details = $this->getOrderDetails($category, $orderId); + if (($details['success'] ?? false) && is_array($details['order'] ?? null)) { + $rawOrder = (array) ($details['order']['meta'] ?? $details['order']); + } + } + + $this->syncOrderToLocal($category, $rawOrder, $userId, $customerId); + $synced++; + $categorySynced++; + } + + $page++; + } while ($categorySynced < ($result['total'] ?? 0)); + } + + return $synced; + } + + /** + * @param array $rawOrder + */ + public function syncOrderToLocal( + string $category, + array $rawOrder, + int $userId, + string $customerId, + ?RcServiceOrder $existingOrder = null + ): RcServiceOrder + { + $normalized = $this->normalizeOrder($category, $rawOrder); + + if ($existingOrder) { + $existingOrder->forceFill([ + 'category' => $category, + 'order_type' => RcServiceOrder::TYPE_SERVICE, + 'domain_name' => $normalized['domain_name'], + 'rc_order_id' => $normalized['rc_order_id'], + 'rc_customer_id' => $customerId, + 'plan_id' => $normalized['plan_id'], + 'plan_name' => $normalized['plan_name'], + 'status' => $normalized['status'], + 'server_location' => $normalized['server_location'], + 'ip_address' => $normalized['ip_address'], + 'access_url' => $normalized['access_url'], + 'access_username' => $normalized['access_username'], + 'expires_at' => $normalized['expires_at'], + 'provisioned_at' => $normalized['provisioned_at'], + 'fulfillment_status' => $normalized['status'] === RcServiceOrder::STATUS_ACTIVE + ? RcServiceOrder::FULFILLMENT_FULFILLED + : $existingOrder->fulfillment_status, + 'last_synced_at' => now(), + 'meta' => $normalized['meta'], + ])->save(); + + $order = $existingOrder->fresh(); + $this->syncPurchasedDomainRegistry($category, $order, $normalized['status']); + + return $order; + } + + $order = RcServiceOrder::query()->updateOrCreate( + [ + 'user_id' => $userId, + 'category' => $category, + 'rc_order_id' => $normalized['rc_order_id'], + ], + [ + 'order_type' => RcServiceOrder::TYPE_SERVICE, + 'domain_name' => $normalized['domain_name'], + 'rc_customer_id' => $customerId, + 'plan_id' => $normalized['plan_id'], + 'plan_name' => $normalized['plan_name'], + 'status' => $normalized['status'], + 'server_location' => $normalized['server_location'], + 'ip_address' => $normalized['ip_address'], + 'access_url' => $normalized['access_url'], + 'access_username' => $normalized['access_username'], + 'expires_at' => $normalized['expires_at'], + 'provisioned_at' => $normalized['provisioned_at'], + 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID, + 'fulfillment_status' => $normalized['status'] === RcServiceOrder::STATUS_ACTIVE + ? RcServiceOrder::FULFILLMENT_FULFILLED + : RcServiceOrder::FULFILLMENT_AWAITING_VENDOR, + 'currency' => $this->sellingCurrency, + 'submitted_at' => now(), + 'last_synced_at' => now(), + 'meta' => $normalized['meta'], + ] + ); + + $this->syncPurchasedDomainRegistry($category, $order, $normalized['status']); + + return $order; + } + + private function syncPurchasedDomainRegistry(string $category, RcServiceOrder $order, string $normalizedStatus): void + { + if (! in_array($category, ['domain-registration', 'domain-transfer'], true)) { + return; + } + + if (! \App\Support\ResellerClubLegacy::integrationEnabled()) { + return; + } + + app(DomainRegistryService::class)->recordFromRcServiceOrder( + $order, + verified: $normalizedStatus === RcServiceOrder::STATUS_ACTIVE, + ); + } + + /** + * @param array $rawOrder + * @return array + */ + private function normalizeOrder(string $category, array $rawOrder): array + { + $domainName = (string) ($rawOrder['domain_name'] ?? $rawOrder['domainname'] ?? $rawOrder['domain-name'] ?? $rawOrder['domain'] ?? $rawOrder['hostname'] ?? $rawOrder['description'] ?? $category); + $status = (string) ($rawOrder['currentstatus'] ?? $rawOrder['orderstatus'] ?? $rawOrder['status'] ?? 'Pending'); + $planId = (string) ($rawOrder['plan_id'] ?? $rawOrder['planid'] ?? $rawOrder['plan-id'] ?? $rawOrder['productkey'] ?? ''); + $planName = (string) ($rawOrder['plan_name'] ?? $rawOrder['productkey'] ?? $rawOrder['planname'] ?? $rawOrder['description'] ?? $planId); + $serverLocation = (string) ($rawOrder['location'] ?? Arr::get($rawOrder, 'meta.location', '') ?? ''); + + return [ + 'rc_order_id' => (string) ($rawOrder['rc_order_id'] ?? $rawOrder['entityid'] ?? $rawOrder['orderid'] ?? $rawOrder['entity-id'] ?? ''), + 'domain_name' => $domainName !== '' ? $domainName : $category, + 'plan_id' => $planId, + 'plan_name' => $this->packageLabel($category, $planId, $planName), + 'status' => $this->normalizeStatus($status), + 'server_location' => (string) ($rawOrder['server_location'] ?? $serverLocation), + 'ip_address' => (string) ($rawOrder['ip_address'] ?? $rawOrder['ipaddress'] ?? $rawOrder['ipAddress'] ?? ''), + 'access_url' => (string) ($rawOrder['access_url'] ?? $rawOrder['cpanelurl'] ?? $rawOrder['controlpanelurl'] ?? $rawOrder['url'] ?? ''), + 'access_username' => (string) ($rawOrder['access_username'] ?? $rawOrder['cpanelusername'] ?? $rawOrder['siteadminusername'] ?? $rawOrder['username'] ?? ''), + 'expires_at' => $this->parseTimestamp($rawOrder['expires_at'] ?? $rawOrder['endtime'] ?? $rawOrder['expirytime'] ?? $rawOrder['expiry-date'] ?? null), + 'provisioned_at' => $this->parseTimestamp($rawOrder['provisioned_at'] ?? $rawOrder['creationtime'] ?? $rawOrder['creation-date'] ?? null), + 'meta' => $rawOrder, + ]; + } + + /** + * @param array $definition + */ + private function endpoint(array $definition, string $action): string + { + return sprintf('%s/%s/%s.json', $this->apiUrl, trim((string) $definition['endpoint'], '/'), $action); + } + + /** + * @return array{success: bool, orders?: array>, total?: int, message?: string} + */ + private function searchOrdersByCustomer(string $category, string $customerId, int $page = 1, int $perPage = 50): array + { + $this->lastSyncError = null; + + $definition = $this->definition($category); + + if ($definition === []) { + $message = 'Unsupported product category.'; + $this->lastSyncError = $message; + + return ['success' => false, 'message' => $message]; + } + + try { + $response = Http::timeout(20)->get($this->endpoint($definition, 'search'), [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + 'customer-id' => $customerId, + 'no-of-records' => $perPage, + 'page-no' => $page, + ]); + + $data = $response->json(); + + if ($response->failed() || ! is_array($data)) { + $message = $this->responseMessage($response, $data, 'Could not fetch service orders.'); + $this->lastSyncError = $message; + + return [ + 'success' => false, + 'message' => $message, + ]; + } + + $countOnPage = (int) ($data['recsonpage'] ?? 0); + $recordCount = (int) ($data['recsindb'] ?? 0); + $orders = []; + + for ($i = 1; $i <= $countOnPage; $i++) { + if (isset($data[(string) $i]) && is_array($data[(string) $i])) { + $orders[] = $data[(string) $i]; + } + } + + return [ + 'success' => true, + 'orders' => $orders, + 'total' => $recordCount, + ]; + } catch (\Throwable $e) { + Log::error('ResellerClubProductService: customer search failed', [ + 'category' => $category, + 'customer_id' => $customerId, + 'error' => $e->getMessage(), + ]); + + $message = $this->syncErrorMessage($e, 'Could not fetch service orders.'); + $this->lastSyncError = $message; + + return ['success' => false, 'message' => $message]; + } + } + + private function configuredApiUrl(): string + { + $value = trim((string) config('mailinfra.registrar_api_url')); + + return $value !== '' ? $value : self::DEFAULT_API_URL; + } + + /** + * @param mixed $data + */ + private function responseMessage(Response $response, mixed $data, string $fallback): string + { + if ($this->looksLikeHtmlError($response)) { + return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.'; + } + + if (is_array($data)) { + return (string) ($data['message'] ?? $data['error'] ?? $fallback); + } + + return $fallback; + } + + private function looksLikeHtmlError(Response $response): bool + { + $contentType = strtolower((string) $response->header('Content-Type')); + $body = trim((string) $response->body()); + + return str_contains($contentType, 'text/html') + || str_starts_with(strtolower($body), ' RcServiceOrder::STATUS_ACTIVE, + 'inactive', 'suspended' => RcServiceOrder::STATUS_SUSPENDED, + 'deleted', 'cancelled' => RcServiceOrder::STATUS_CANCELLED, + 'expired' => RcServiceOrder::STATUS_EXPIRED, + 'failed' => RcServiceOrder::STATUS_FAILED, + default => RcServiceOrder::STATUS_PENDING, + }; + } + + private function parseTimestamp(mixed $value): ?Carbon + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return Carbon::createFromTimestamp((int) $value); + } + + try { + return Carbon::parse((string) $value); + } catch (\Throwable) { + return null; + } + } + + private function syncErrorMessage(\Throwable $e, string $fallback): string + { + $message = $e->getMessage(); + + if (str_contains($message, 'Could not resolve host')) { + return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.'; + } + + return $fallback; + } + + /** + * @return array> + */ + private function packagesForCategory(string $category): array + { + if (! $this->isConfigured()) { + return []; + } + + $definition = $this->definition($category); + if ($definition === []) { + return []; + } + + $cacheKey = 'rc-catalog-packages:'.$category.':'.md5(json_encode([ + 'catalog_keys' => $definition['catalog_keys'] ?? [], + 'selling_currency' => $this->sellingCurrency, + ])); + + return Cache::remember( + $cacheKey, + now()->addMinutes($this->catalogCacheTtlMinutes()), + function () use ($category, $definition): array { + $catalogKeys = $this->catalogLookupKeys($definition); + $planDetailsEntry = $this->catalogEntry($this->fetchPlanDetailsCatalog(), $catalogKeys); + $pricingEntry = $this->catalogEntry($this->fetchCustomerPricingCatalog(), $catalogKeys); + + return $this->buildPackages($category, $planDetailsEntry, $pricingEntry); + } + ); + } + + /** + * @return array + */ + private function fetchPlanDetailsCatalog(): array + { + return Cache::remember( + 'rc-catalog-plan-details', + now()->addMinutes($this->catalogCacheTtlMinutes()), + function (): array { + try { + $response = Http::timeout(30)->get($this->apiUrl.'/products/plan-details.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ]); + + $data = $response->json(); + + return $response->successful() && is_array($data) ? $data : []; + } catch (\Throwable $e) { + Log::warning('ResellerClubProductService: plan-details catalog fetch failed', [ + 'error' => $e->getMessage(), + ]); + + return []; + } + } + ); + } + + /** + * @return array + */ + private function fetchCustomerPricingCatalog(): array + { + return Cache::remember( + 'rc-catalog-customer-pricing', + now()->addMinutes($this->catalogCacheTtlMinutes()), + function (): array { + try { + $response = Http::timeout(30)->get($this->apiUrl.'/products/customer-price.json', [ + 'auth-userid' => $this->authUserId, + 'api-key' => $this->apiKey, + ]); + + $data = $response->json(); + + return $response->successful() && is_array($data) ? $data : []; + } catch (\Throwable $e) { + Log::warning('ResellerClubProductService: customer-price catalog fetch failed', [ + 'error' => $e->getMessage(), + ]); + + return []; + } + } + ); + } + + /** + * @param array $catalog + * @param array $keys + * @return array + */ + private function catalogEntry(array $catalog, array $keys): array + { + foreach ($keys as $key) { + $entry = $catalog[$key] ?? null; + if (is_array($entry)) { + return $entry; + } + } + + return []; + } + + /** + * @param array $definition + * @return array + */ + private function catalogLookupKeys(array $definition): array + { + $keys = []; + + foreach ((array) ($definition['catalog_keys'] ?? []) as $key) { + $normalized = strtolower(trim((string) $key)); + if ($normalized !== '') { + $keys[] = $normalized; + } + } + + $endpoint = trim((string) ($definition['endpoint'] ?? ''), '/'); + if ($endpoint !== '') { + $keys[] = strtolower(str_replace('/', '', $endpoint)); + + $firstSegment = strtolower((string) strtok($endpoint, '/')); + if ($firstSegment !== '') { + $keys[] = $firstSegment; + } + } + + return array_values(array_unique($keys)); + } + + /** + * @param array $planDetailsEntry + * @param array $pricingEntry + * @return array> + */ + private function buildPackages(string $category, array $planDetailsEntry, array $pricingEntry): array + { + $packages = []; + $planDetails = $this->normalizePlanDetailsEntry($planDetailsEntry); + $pricingPlans = $this->normalizePricingEntry($pricingEntry); + + foreach ($planDetails as $planId => $detail) { + $planId = (string) $planId; + if ($planId === '' || ! is_array($detail)) { + continue; + } + + $priceValues = $this->extractPlanPriceValues($pricingPlans[$planId] ?? [], 'add'); + $renewPriceValues = $this->extractPlanPriceValues($pricingPlans[$planId] ?? [], 'renew'); + $formatted = $this->formatPlanPrices($priceValues); + $renewFormatted = $this->formatPlanPrices($renewPriceValues); + $package = [ + 'value' => $planId, + 'label' => trim((string) ($detail['name'] ?? $detail['planname'] ?? $detail['plan_name'] ?? 'Plan '.$planId)), + 'prices' => $formatted['monthly'], + 'totals' => $formatted['totals'], + 'renew_prices' => $renewFormatted['monthly'], + 'renew_totals' => $renewFormatted['totals'], + 'amounts_minor' => $this->amountsMinor($priceValues), + 'renew_amounts_minor' => $this->amountsMinor($renewPriceValues), + 'currency' => $this->sellingCurrency, + 'starting_term' => $this->startingTerm($formatted), + 'starting_price' => $this->startingPrice($formatted), + 'starting_amount' => $this->startingAmount($priceValues), + 'summary' => null, + ]; + + $package['summary'] = $this->packageSummary($category, $package['label'], $detail); + + $package['starting_label'] = $package['starting_term'] !== null && $package['starting_price'] !== null + ? sprintf('%s/mo', $package['starting_price']) + : null; + + $packages[] = $package; + } + + if ($packages === []) { + foreach ($pricingPlans as $planId => $pricingDetail) { + $planId = (string) $planId; + $priceValues = $this->extractPlanPriceValues(is_array($pricingDetail) ? $pricingDetail : [], 'add'); + $renewPriceValues = $this->extractPlanPriceValues(is_array($pricingDetail) ? $pricingDetail : [], 'renew'); + $formatted = $this->formatPlanPrices($priceValues); + $renewFormatted = $this->formatPlanPrices($renewPriceValues); + if (($formatted['monthly'] ?? []) === []) { + continue; + } + + $packages[] = [ + 'value' => $planId, + 'label' => 'Plan '.$planId, + 'prices' => $formatted['monthly'], + 'totals' => $formatted['totals'], + 'renew_prices' => $renewFormatted['monthly'], + 'renew_totals' => $renewFormatted['totals'], + 'amounts_minor' => $this->amountsMinor($priceValues), + 'renew_amounts_minor' => $this->amountsMinor($renewPriceValues), + 'currency' => $this->sellingCurrency, + 'starting_term' => $this->startingTerm($formatted), + 'starting_price' => $this->startingPrice($formatted), + 'starting_amount' => $this->startingAmount($priceValues), + 'starting_label' => $this->startingTerm($formatted) !== null && $this->startingPrice($formatted) !== null + ? sprintf('%s/mo', $this->startingPrice($formatted)) + : null, + 'summary' => null, + ]; + } + } + + return collect($packages) + ->filter(fn (array $package) => ($package['prices'] ?? []) !== []) + ->sortBy(fn (array $package) => [$package['starting_amount'] ?? INF, $package['label']]) + ->values() + ->all(); + } + + /** + * @param array $detail + */ + private function packageSummary(string $category, string $label, array $detail): ?string + { + $normalizedLabel = strtolower(trim($label)); + + return match ($category) { + 'multi-domain-hosting' => match ($normalizedLabel) { + 'business' => 'For up to 3 domains', + 'pro' => 'For unlimited domains', + default => $this->planSummary($detail), + }, + default => $this->planSummary($detail), + }; + } + + /** + * @param array $entry + * @return array> + */ + private function normalizePlanDetailsEntry(array $entry): array + { + if (isset($entry['plans']) && is_array($entry['plans'])) { + return array_filter($entry['plans'], 'is_array'); + } + + return array_filter($entry, 'is_array'); + } + + /** + * @param array $entry + * @return array> + */ + private function normalizePricingEntry(array $entry): array + { + if (isset($entry['plans']) && is_array($entry['plans'])) { + return array_filter($entry['plans'], 'is_array'); + } + + return array_filter($entry, 'is_array'); + } + + /** + * @param array $planPricing + * @return array + */ + private function extractPlanPriceValues(array $planPricing, string $action = 'add'): array + { + $directCandidates = [ + $this->normalizeTermPriceMap((array) ($planPricing[$action] ?? [])), + $this->normalizeTermPriceMap((array) Arr::get($planPricing, 'pricing.'.$action, [])), + $this->normalizeTermPriceMap((array) Arr::get($planPricing, 'prices.'.$action, [])), + ]; + + $directMatch = $this->bestTermPriceMap($directCandidates); + if ($directMatch !== []) { + return $directMatch; + } + + $candidates = []; + $this->collectActionTermPriceMaps($planPricing, $action, $candidates); + + $actionMatch = $this->bestTermPriceMap($candidates); + if ($actionMatch !== []) { + return $actionMatch; + } + + if ($action !== 'add') { + return []; + } + + $candidates = []; + $this->collectTermPriceMaps($planPricing, $candidates); + + return $this->bestTermPriceMap($candidates); + } + + /** + * @param array $candidate + * @return array + */ + private function normalizeTermPriceMap(array $candidate): array + { + $prices = []; + + foreach ($candidate as $term => $price) { + if (! is_numeric($term) || ! is_numeric($price)) { + continue; + } + + $prices[(string) $term] = round((float) $price, 2); + } + + ksort($prices, SORT_NATURAL); + + return $prices; + } + + /** + * @param array $payload + * @param array> $candidates + */ + private function collectTermPriceMaps(array $payload, array &$candidates): void + { + $normalized = $this->normalizeTermPriceMap($payload); + if ($normalized !== []) { + $candidates[] = $normalized; + } + + foreach ($payload as $value) { + if (is_array($value)) { + $this->collectTermPriceMaps($value, $candidates); + } + } + } + + /** + * @param array $payload + * @param array> $candidates + */ + private function collectActionTermPriceMaps(array $payload, string $action, array &$candidates): void + { + $candidate = $payload[$action] ?? null; + + if (is_array($candidate)) { + $normalized = $this->normalizeTermPriceMap($candidate); + if ($normalized !== []) { + $candidates[] = $normalized; + } + } + + foreach ($payload as $value) { + if (is_array($value)) { + $this->collectActionTermPriceMaps($value, $action, $candidates); + } + } + } + + /** + * @param array> $candidates + * @return array + */ + private function bestTermPriceMap(array $candidates): array + { + $best = []; + $bestScore = -1; + + foreach ($candidates as $candidate) { + if ($candidate === []) { + continue; + } + + $terms = array_map(static fn (string $term): int => (int) $term, array_keys($candidate)); + $score = (count($candidate) * 1000) + max($terms); + + if ($score > $bestScore) { + $best = $candidate; + $bestScore = $score; + } + } + + return $best; + } + + /** + * RC telescopic product prices are per-month frontfacing prices for each term. + * + * @param array $priceValues + * @return array{monthly: array, totals: array} + */ + private function formatPlanPrices(array $priceValues): array + { + $monthly = []; + $totals = []; + + foreach ($priceValues as $term => $value) { + $months = max(1, (int) $term); + $monthly[(string) $term] = $this->formatDisplayPrice((float) $value); + $totals[(string) $term] = $this->formatDisplayPrice(round((float) $value * $months, 2)); + } + + return ['monthly' => $monthly, 'totals' => $totals]; + } + + /** + * @param array{monthly: array, totals: array} $formatted + */ + private function startingTerm(array $formatted): ?string + { + $terms = array_keys($formatted['monthly'] ?? []); + + return $terms[0] ?? null; + } + + /** + * @param array{monthly: array, totals: array} $formatted + */ + private function startingPrice(array $formatted): ?string + { + $term = $this->startingTerm($formatted); + + return $term !== null ? ($formatted['monthly'][$term] ?? null) : null; + } + + /** + * @param array $priceValues + */ + private function startingAmount(array $priceValues): ?float + { + $term = array_key_first($priceValues); + if ($term === null) { + return null; + } + + return round((float) ($priceValues[$term] ?? 0), 2); + } + + /** + * @param array $priceValues + * @return array + */ + private function amountsMinor(array $priceValues): array + { + $amounts = []; + + foreach ($priceValues as $term => $value) { + $months = max(1, (int) $term); + $amounts[(string) $term] = (int) round($value * $months * 100); + } + + return $amounts; + } + + /** + * @param array $detail + */ + private function planSummary(array $detail): ?string + { + $parts = []; + + foreach (['diskspace', 'disk_space', 'storage', 'bandwidth', 'ram', 'memory', 'vcpus', 'emails'] as $field) { + $value = $this->stringifySummaryField($field, $detail[$field] ?? null); + if ($value === null) { + continue; + } + + $parts[] = ucfirst(str_replace('_', ' ', $field)).': '.$value; + } + + if ($parts === []) { + $description = $this->stringifyPlanField($detail['description'] ?? null); + if ($description !== null) { + return $description; + } + } + + return $parts !== [] ? implode(' • ', $parts) : null; + } + + private function stringifySummaryField(string $field, mixed $value): ?string + { + $normalized = $this->stringifyPlanField($value); + if ($normalized === null) { + return null; + } + + if (in_array($field, ['diskspace', 'disk_space', 'storage', 'bandwidth', 'emails'], true) + && in_array($normalized, ['-1', '-1.0'], true)) { + return 'Unlimited'; + } + + return $normalized; + } + + private function stringifyPlanField(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $normalized = trim($value); + + return $normalized !== '' ? $normalized : null; + } + + if (is_numeric($value) || is_bool($value)) { + return (string) $value; + } + + if (! is_array($value)) { + return null; + } + + $parts = []; + + foreach ($value as $item) { + $normalized = $this->stringifyPlanField($item); + if ($normalized !== null) { + $parts[] = $normalized; + } + } + + $parts = array_values(array_unique($parts)); + + return $parts !== [] ? implode(', ', $parts) : null; + } + + private function catalogCacheTtlMinutes(): int + { + return max(1, (int) config('mailinfra.resellerclub_catalog_cache_ttl_minutes', 30)); + } + + private function formatDisplayPrice(float $amount): string + { + return $this->sellingCurrency.' '.number_format($amount, 2, '.', ','); + } + + /** + * @return array{success: bool, amount_minor?: int, currency?: string, plan_name?: string, message?: string} + */ + public function quoteForSelection(string $category, string $planId, int $months): array + { + $package = collect($this->packagesForCategory($category)) + ->first(fn (array $item) => (string) ($item['value'] ?? '') === $planId); + + if (! $package) { + return ['success' => false, 'message' => 'The selected package is no longer available.']; + } + + $term = (string) max(1, $months); + $amountMinor = (int) (($package['amounts_minor'][$term] ?? 0)); + + if ($amountMinor <= 0) { + return ['success' => false, 'message' => 'The selected billing term is no longer available.']; + } + + return [ + 'success' => true, + 'amount_minor' => $amountMinor, + 'currency' => (string) ($package['currency'] ?? $this->sellingCurrency), + 'plan_name' => (string) ($package['label'] ?? $planId), + ]; + } +} diff --git a/app/Services/Ssl/SslCertificateExpiryParser.php b/app/Services/Ssl/SslCertificateExpiryParser.php new file mode 100644 index 0000000..8851c27 --- /dev/null +++ b/app/Services/Ssl/SslCertificateExpiryParser.php @@ -0,0 +1,29 @@ +host, '.'); + $webroot = config('mailinfra.ssl_webroot'); + $email = config('mailinfra.ssl_email'); + + $domains = escapeshellarg($domainName); + $wwwDomain = 'www.' . $domainName; + + if ($this->domainResolvesToServer($wwwDomain)) { + $domains .= ' -d ' . escapeshellarg($wwwDomain); + } else { + Log::info('SslProvisioner: www subdomain does not resolve, skipping', ['domain' => $wwwDomain]); + } + + $commands = [ + sprintf( + 'certbot certonly --webroot -w %s -d %s --non-interactive --agree-tos -m %s', + escapeshellarg($webroot), + $domains, + escapeshellarg($email) + ), + ]; + + [$success, $output] = $this->sshExec($commands); + + if (! $success) { + Log::error('SslProvisioner: certbot failed', [ + 'domain' => $domainName, + 'output' => $output, + ]); + + return false; + } + + Log::info('SslProvisioner: certificate issued', ['domain' => $domainName]); + + return true; + } + + /** + * @return array{success: bool, output: string, exit_code: int} + */ + public function renewCertificates(bool $force = false): array + { + $command = 'certbot renew --quiet --no-random-sleep-on-renew'; + if ($force) { + $command .= ' --force-renewal'; + } + + [$success, $output, $exitCode] = $this->sshExecWithExitCode([$command]); + + if (! $success) { + Log::warning('SslProvisioner: certbot renew completed with errors', [ + 'output' => $output, + 'exit_code' => $exitCode, + ]); + } else { + Log::info('SslProvisioner: certbot renew completed'); + } + + return [ + 'success' => $success, + 'output' => $output, + 'exit_code' => $exitCode, + ]; + } + + public function readCertificateExpiry(string $domainName): ?CarbonInterface + { + $domainName = rtrim($domainName, '.'); + $certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem"; + $command = sprintf( + 'openssl x509 -in %s -noout -enddate 2>/dev/null', + escapeshellarg($certPath) + ); + + [$success, $output] = $this->sshExec([$command]); + + if (! $success) { + return null; + } + + return $this->expiryParser->parseOpenSslEndDate($output); + } + + private function domainResolvesToServer(string $hostname): bool + { + if (! function_exists('dns_get_record')) { + return false; + } + + $records = @dns_get_record($hostname, DNS_A); + if (! is_array($records) || $records === []) { + $cname = @dns_get_record($hostname, DNS_CNAME); + + return is_array($cname) && $cname !== []; + } + + return true; + } + + public function generateNginxConfig(Domain $domain): bool + { + $domainName = rtrim($domain->host, '.'); + $confDir = config('mailinfra.ssl_nginx_conf_dir', '/etc/nginx/sites-enabled'); + $confPath = "{$confDir}/{$domainName}.conf"; + + // Check if this domain has a hosted site - use its document root instead of default + $hostedSite = HostedSite::where('domain', $domainName)->first(); + if ($hostedSite) { + $includeWww = $this->domainResolvesToServer('www.' . $domainName); + $config = $this->buildHostedSiteNginxServerBlock($hostedSite, $includeWww); + } else { + $webroot = config('mailinfra.ssl_webroot'); + $includeWww = $this->domainResolvesToServer('www.' . $domainName); + $config = $this->buildNginxServerBlock($domainName, $webroot, $includeWww); + } + + $escapedConfig = str_replace("'", "'\\''", $config); + + $commands = [ + "echo '{$escapedConfig}' > {$confPath}", + ]; + + [$success, $output] = $this->sshExec($commands); + + if (! $success) { + Log::error('SslProvisioner: Nginx config generation failed', [ + 'domain' => $domainName, + 'output' => $output, + ]); + + return false; + } + + Log::info('SslProvisioner: Nginx config written', ['domain' => $domainName, 'path' => $confPath]); + + return true; + } + + public function reloadNginx(): bool + { + [$success, $output] = $this->sshExec(['nginx -t && systemctl reload nginx']); + + if (! $success) { + Log::error('SslProvisioner: Nginx reload failed', ['output' => $output]); + + return false; + } + + return true; + } + + private function buildNginxServerBlock(string $domainName, string $webroot, bool $includeWww = true): string + { + $certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem"; + $serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName; + + return <<domain; + $docRoot = $site->document_root; + $username = $site->account?->username ?? 'www-data'; + $phpVersion = $site->php_version ?? '8.3'; + $certPath = "/etc/letsencrypt/live/{$domainName}/fullchain.pem"; + $keyPath = "/etc/letsencrypt/live/{$domainName}/privkey.pem"; + $serverNames = $includeWww ? "{$domainName} www.{$domainName}" : $domainName; + + return <<sshExecWithExitCode($commands); + + return [$success, $output]; + } + + /** + * @return array{0: bool, 1: string, 2: int} + */ + private function sshExecWithExitCode(array $commands): array + { + $sshHost = config('mailinfra.web_ssh_host'); + $sshUser = config('mailinfra.web_ssh_user', 'root'); + $sshKeyPath = config('mailinfra.web_ssh_key_path'); + + if (empty($sshHost) || empty($sshKeyPath)) { + return [false, 'SSH not configured', 1]; + } + + $sshBase = sprintf( + 'ssh -i %s -o StrictHostKeyChecking=no %s@%s', + escapeshellarg($sshKeyPath), + escapeshellarg($sshUser), + escapeshellarg($sshHost) + ); + + $fullCommand = $sshBase . ' ' . escapeshellarg(implode(' && ', $commands)); + + $output = []; + $exitCode = 0; + exec($fullCommand . ' 2>&1', $output, $exitCode); + + return [$exitCode === 0, implode("\n", $output), $exitCode]; + } +} diff --git a/app/Services/Ssl/SslRenewalService.php b/app/Services/Ssl/SslRenewalService.php new file mode 100644 index 0000000..f046319 --- /dev/null +++ b/app/Services/Ssl/SslRenewalService.php @@ -0,0 +1,329 @@ + 0, + 'central' => false, + 'server_orders' => 0, + 'sites_synced' => 0, + 'domains_synced' => 0, + 'fallbacks' => 0, + ]; + + if ($dryRun) { + $stats['nodes'] = $this->sharedNodesWithSsl()->count(); + $stats['central'] = $this->sslProvisioner->isConfigured() && $this->centralDomainsNeedingRenewal()->isNotEmpty(); + $stats['server_orders'] = $this->managedServerOrdersNeedingRenewal()->count(); + + return $stats; + } + + foreach ($this->sharedNodesWithSsl() as $node) { + try { + $this->nodeProvider->renewCertificatesOnNode($node, $force); + $stats['nodes']++; + } catch (\Throwable $exception) { + Log::error('SslRenewalService: node renew failed', [ + 'node_id' => $node->id, + 'error' => $exception->getMessage(), + ]); + } + + $stats['sites_synced'] += $this->syncHostedSiteExpiriesForNode($node); + } + + if ($this->sslProvisioner->isConfigured()) { + $centralDomains = $this->centralDomainsNeedingRenewal(); + if ($centralDomains->isNotEmpty()) { + $this->sslProvisioner->renewCertificates($force); + $this->sslProvisioner->reloadNginx(); + $stats['central'] = true; + $stats['domains_synced'] += $this->syncCentralDomainExpiries($centralDomains); + } + } + + $stats['server_orders'] = $this->queueManagedServerRenewals(); + $stats['fallbacks'] = $this->reissueExpiringHostedSites(); + + $this->notifyExpiringDomains(); + + return $stats; + } + + /** + * @return Collection + */ + private function sharedNodesWithSsl(): Collection + { + $nodeIds = HostedSite::query() + ->where('ssl_enabled', true) + ->where('status', 'active') + ->whereHas('account', function ($query): void { + $query->where('status', 'active') + ->whereNotNull('hosting_node_id'); + }) + ->with('account:id,hosting_node_id') + ->get() + ->pluck('account.hosting_node_id') + ->filter() + ->unique() + ->values(); + + if ($nodeIds->isEmpty()) { + return collect(); + } + + return HostingNode::query() + ->whereIn('id', $nodeIds) + ->where('type', HostingNode::TYPE_SHARED) + ->where('status', 'active') + ->get(); + } + + public function syncHostedSiteExpiriesForNode(HostingNode $node): int + { + $sites = HostedSite::query() + ->where('ssl_enabled', true) + ->where('status', 'active') + ->whereHas('account', function ($query) use ($node): void { + $query->where('status', 'active') + ->where('hosting_node_id', $node->id); + }) + ->get(); + + $synced = 0; + + foreach ($sites as $site) { + $expiresAt = $this->readHostedSiteExpiry($node, $site); + if ($expiresAt === null) { + continue; + } + + $site->update(['ssl_expires_at' => $expiresAt]); + $synced++; + + if ($site->domain_id) { + Domain::query() + ->whereKey($site->domain_id) + ->update([ + 'ssl_status' => 'active', + 'ssl_expires_at' => $expiresAt, + ]); + } + } + + return $synced; + } + + private function readHostedSiteExpiry(HostingNode $node, HostedSite $site): ?CarbonInterface + { + try { + return $this->nodeProvider->readCertificateExpiryOnNode($node, $site->domain); + } catch (\Throwable $exception) { + Log::warning('SslRenewalService: could not read certificate expiry', [ + 'site_id' => $site->id, + 'domain' => $site->domain, + 'error' => $exception->getMessage(), + ]); + + return null; + } + } + + /** + * Domains with active SSL on the central web server (not served from a shared node cert). + * + * @return Collection + */ + private function centralDomainsNeedingRenewal(): Collection + { + return Domain::query() + ->where('ssl_status', 'active') + ->whereDoesntHave('hostedSites', function ($query): void { + $query->where('ssl_enabled', true) + ->where('status', 'active') + ->whereHas('account', fn ($account) => $account->where('status', 'active')); + }) + ->get(); + } + + /** + * @param Collection $domains + */ + private function syncCentralDomainExpiries(Collection $domains): int + { + $synced = 0; + + foreach ($domains as $domain) { + $expiresAt = $this->sslProvisioner->readCertificateExpiry($domain->host); + if ($expiresAt === null) { + continue; + } + + $domain->update(['ssl_expires_at' => $expiresAt]); + $synced++; + } + + return $synced; + } + + private function queueManagedServerRenewals(): int + { + $queued = 0; + + foreach ($this->managedServerOrdersNeedingRenewal() as $order) { + $existing = ServerTask::query() + ->where('customer_hosting_order_id', $order->id) + ->where('type', ServerTask::TYPE_SSL_RENEW) + ->whereIn('status', [ + ServerTask::STATUS_QUEUED, + ServerTask::STATUS_DISPATCHED, + ServerTask::STATUS_RUNNING, + ]) + ->exists(); + + if ($existing) { + continue; + } + + ServerTask::create([ + 'customer_hosting_order_id' => $order->id, + 'server_agent_id' => $order->serverAgent?->id, + 'type' => ServerTask::TYPE_SSL_RENEW, + 'status' => ServerTask::STATUS_QUEUED, + 'payload' => [], + 'progress' => 0, + 'max_attempts' => 3, + 'retry_backoff_seconds' => 60, + 'stale_after_seconds' => 300, + 'queued_at' => now(), + 'available_at' => now(), + ]); + + $queued++; + } + + return $queued; + } + + /** + * @return Collection + */ + private function managedServerOrdersNeedingRenewal(): Collection + { + $orderIds = ServerSite::query() + ->whereIn('ssl_status', ['issued', 'active']) + ->pluck('customer_hosting_order_id') + ->unique() + ->filter(); + + if ($orderIds->isEmpty()) { + return collect(); + } + + return CustomerHostingOrder::query() + ->whereIn('id', $orderIds) + ->whereHas('serverAgent') + ->with('serverAgent') + ->get(); + } + + private function reissueExpiringHostedSites(): int + { + $thresholdDays = (int) config('ssl.fallback_reissue_before_days', 10); + $threshold = now()->addDays($thresholdDays); + + $sites = HostedSite::query() + ->where('ssl_enabled', true) + ->where('status', 'active') + ->where(function ($query) use ($threshold): void { + $query->whereNull('ssl_expires_at') + ->orWhere('ssl_expires_at', '<=', $threshold); + }) + ->whereHas('account', fn ($query) => $query->where('status', 'active')) + ->with(['account.node', 'account.user']) + ->get(); + + $reissued = 0; + + foreach ($sites as $site) { + if (! $site->account?->node) { + continue; + } + + try { + $this->nodeProvider->requestLetsEncryptCertificate($site); + $expiresAt = $this->readHostedSiteExpiry($site->account->node, $site); + $site->update(array_filter([ + 'ssl_status' => 'issued', + 'ssl_error' => null, + 'ssl_expires_at' => $expiresAt, + 'ssl_provisioned_at' => now(), + ])); + $reissued++; + } catch (\Throwable $exception) { + Log::warning('SslRenewalService: fallback re-issue failed', [ + 'site_id' => $site->id, + 'domain' => $site->domain, + 'error' => $exception->getMessage(), + ]); + } + } + + return $reissued; + } + + private function notifyExpiringDomains(): void + { + Domain::query() + ->where('ssl_status', 'active') + ->whereNotNull('ssl_expires_at') + ->where('ssl_expires_at', '>', now()) + ->with('website.user') + ->each(function (Domain $domain): void { + $user = $domain->website?->user; + if ($user === null) { + return; + } + + $daysUntilExpiry = (int) now()->startOfDay()->diffInDays( + $domain->ssl_expires_at->copy()->startOfDay(), + false + ); + + $milestones = array_map('intval', (array) config('notifications.ssl_expiry_milestones', [14, 7, 3, 1])); + + if (! in_array($daysUntilExpiry, $milestones, true)) { + return; + } + + $user->notify(new SslExpiringNotification($domain, $daysUntilExpiry)); + }); + } +} diff --git a/app/Support/DomainConfig.php b/app/Support/DomainConfig.php new file mode 100644 index 0000000..71f7d25 --- /dev/null +++ b/app/Support/DomainConfig.php @@ -0,0 +1,369 @@ + + */ + public static function featuredTlds(): array + { + $allTlds = self::allTlds(); + $featured = array_values(array_intersect(self::PRIORITY_TLDS, $allTlds)); + + if ($featured !== []) { + return $featured; + } + + return array_slice($allTlds, 0, self::DEFAULT_PAGE_SIZE); + } + + /** + * Get all available TLDs sorted by priority. + * + * @return list + */ + public static function allTlds(): array + { + $current = self::normalizeTlds(config('domain.search_tlds', [])); + $legacy = self::normalizeTlds(config('mailinfra.domain_search_tlds', [])); + + $tlds = $legacy !== [] ? $legacy : $current; + $livePricedTlds = self::livePricedTlds(); + + if ($livePricedTlds === []) { + return []; + } + + $livePricedLookup = array_flip($livePricedTlds); + $tlds = array_values(array_filter( + $tlds, + static fn (string $tld): bool => isset($livePricedLookup[$tld]) + )); + + return self::sortWithPriority($tlds); + } + + /** + * Get paginated TLDs (excluding featured ones). + * + * @return array{tlds: list, hasMore: bool, total: int} + */ + public static function paginatedTlds(int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array + { + $allTlds = self::allTlds(); + $featured = self::featuredTlds(); + + $remaining = array_values(array_filter($allTlds, fn ($tld) => !in_array($tld, $featured, true))); + $total = count($remaining); + + $offset = ($page - 1) * $perPage; + $tlds = array_slice($remaining, $offset, $perPage); + + return [ + 'tlds' => $tlds, + 'hasMore' => ($offset + $perPage) < $total, + 'total' => $total, + ]; + } + + /** + * @deprecated Use featuredTlds() or allTlds() instead + * @return list + */ + public static function searchTlds(): array + { + return self::featuredTlds(); + } + + /** + * Build the TLD list and query term for a search page request. + * + * @return array{query: string, tlds: list, hasMore: bool, exact_domain: string|null} + */ + public static function searchPageConfig(string $query, int $page = 1, int $perPage = self::DEFAULT_PAGE_SIZE): array + { + $query = strtolower(trim($query)); + $allTlds = self::allTlds(); + + if ($allTlds === []) { + return [ + 'query' => $query, + 'tlds' => [], + 'hasMore' => false, + 'exact_domain' => null, + ]; + } + + $exactDomain = self::normalizeExactDomain($query); + + if ($exactDomain === null) { + if ($page === 1) { + $featured = self::featuredTlds(); + $paginatedData = self::paginatedTlds(1, $perPage); + + return [ + 'query' => $query, + 'tlds' => $featured, + 'hasMore' => $paginatedData['hasMore'] || count($paginatedData['tlds']) > 0, + 'exact_domain' => null, + ]; + } + + $paginatedData = self::paginatedTlds($page - 1, $perPage); + + return [ + 'query' => $query, + 'tlds' => $paginatedData['tlds'], + 'hasMore' => $paginatedData['hasMore'], + 'exact_domain' => null, + ]; + } + + $exactTld = self::extractTld($exactDomain); + $keyword = explode('.', $exactDomain, 2)[0] ?? $exactDomain; + $firstPageTlds = array_values(array_unique(array_filter([ + $exactTld, + ...self::featuredTlds(), + ]))); + $remainingTlds = array_values(array_filter( + $allTlds, + static fn (string $tld): bool => ! in_array($tld, $firstPageTlds, true) + )); + + if ($page === 1) { + return [ + 'query' => $keyword, + 'tlds' => $firstPageTlds, + 'hasMore' => $remainingTlds !== [], + 'exact_domain' => $exactDomain, + ]; + } + + $offset = max(0, ($page - 2) * $perPage); + + return [ + 'query' => $keyword, + 'tlds' => array_slice($remainingTlds, $offset, $perPage), + 'hasMore' => ($offset + $perPage) < count($remainingTlds), + 'exact_domain' => $exactDomain, + ]; + } + + /** + * Sort domain search results by TLD priority. + * + * @param array $results + * @return array + */ + public static function sortResultsByTldPriority(array $results, ?string $query = null): array + { + $priorityMap = array_flip(self::PRIORITY_TLDS); + $exactDomain = self::normalizeExactDomain((string) $query); + + usort($results, static function (array $a, array $b) use ($priorityMap, $exactDomain): int { + $aDomain = strtolower((string) ($a['domain'] ?? '')); + $bDomain = strtolower((string) ($b['domain'] ?? '')); + + $aExact = $exactDomain !== null && $aDomain === $exactDomain ? 0 : 1; + $bExact = $exactDomain !== null && $bDomain === $exactDomain ? 0 : 1; + + if ($aExact !== $bExact) { + return $aExact <=> $bExact; + } + + $aPremium = self::isPremiumResult($a) ? 0 : 1; + $bPremium = self::isPremiumResult($b) ? 0 : 1; + + if ($aPremium !== $bPremium) { + return $aPremium <=> $bPremium; + } + + $aAvailable = ! empty($a['available']) ? 0 : 1; + $bAvailable = ! empty($b['available']) ? 0 : 1; + + if ($aAvailable !== $bAvailable) { + return $aAvailable <=> $bAvailable; + } + + $aTld = self::extractTld($a['domain'] ?? ''); + $bTld = self::extractTld($b['domain'] ?? ''); + + $aPriority = $priorityMap[$aTld] ?? PHP_INT_MAX; + $bPriority = $priorityMap[$bTld] ?? PHP_INT_MAX; + + if ($aPriority !== $bPriority) { + return $aPriority <=> $bPriority; + } + + return $aDomain <=> $bDomain; + }); + + return $results; + } + + /** + * Extract TLD from a domain name. + */ + private static function extractTld(string $domain): string + { + $parts = explode('.', strtolower($domain), 2); + + return $parts[1] ?? ''; + } + + private static function normalizeExactDomain(string $query): ?string + { + $query = strtolower(trim($query)); + + if ($query === '' || str_starts_with($query, '.') || str_ends_with($query, '.') || ! str_contains($query, '.')) { + return null; + } + + return preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $query) === 1 + ? $query + : null; + } + + /** + * @param array $result + */ + private static function isPremiumResult(array $result): bool + { + $premium = $result['premium'] ?? false; + + if (is_bool($premium)) { + return $premium; + } + + return in_array(strtolower(trim((string) $premium)), ['1', 'yes', 'true', 'premium'], true); + } + + /** + * @param list $tlds + * @return list + */ + private static function sortWithPriority(array $tlds): array + { + $priorityMap = array_flip(self::PRIORITY_TLDS); + $pricingMap = self::pricingMapForTlds($tlds); + + usort($tlds, static function (string $a, string $b) use ($priorityMap, $pricingMap): int { + $aPriority = $priorityMap[$a] ?? PHP_INT_MAX; + $bPriority = $priorityMap[$b] ?? PHP_INT_MAX; + + if ($aPriority !== $bPriority) { + return $aPriority <=> $bPriority; + } + + $aPrice = (int) ($pricingMap[$a]['register'] ?? 0); + $bPrice = (int) ($pricingMap[$b]['register'] ?? 0); + + if ($aPrice !== $bPrice) { + return $bPrice <=> $aPrice; + } + + return $a <=> $b; + }); + + return $tlds; + } + + /** + * @return array + */ + public static function resellerClubTldProductKeys(): array + { + return array_replace( + self::normalizeProductKeyMap(config('domain.resellerclub.tld_product_keys', [])), + self::normalizeProductKeyMap(config('mailinfra.resellerclub_tld_product_keys', [])) + ); + } + + public static function resellerClubProductKeyCacheTtlSeconds(): int + { + $ttl = (int) config('domain.resellerclub.product_key_cache_ttl_seconds', 86400); + + return $ttl > 0 ? $ttl : 86400; + } + + /** + * @param mixed $value + * @return list + */ + private static function normalizeTlds(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_unique(array_filter(array_map( + static fn ($tld): string => ltrim(strtolower(trim((string) $tld)), '.'), + $value + )))); + } + + /** + * @param mixed $value + * @return array + */ + private static function normalizeProductKeyMap(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $map = []; + + foreach ($value as $tld => $productKey) { + $normalizedTld = ltrim(strtolower(trim((string) $tld)), '.'); + $normalizedProductKey = trim((string) $productKey); + + if ($normalizedTld === '' || $normalizedProductKey === '') { + continue; + } + + $map[$normalizedTld] = $normalizedProductKey; + } + + return $map; + } + + /** + * @return list + */ + private static function livePricedTlds(): array + { + try { + return app(DomainPricingService::class)->getLivePricedTlds(); + } catch (\Throwable) { + return []; + } + } + + /** + * @param list $tlds + * @return array + */ + private static function pricingMapForTlds(array $tlds): array + { + try { + return app(DomainPricingService::class)->getPricingForTlds($tlds); + } catch (\Throwable) { + return []; + } + } +} diff --git a/app/Support/DomainGlobeIcon.php b/app/Support/DomainGlobeIcon.php new file mode 100644 index 0000000..747265f --- /dev/null +++ b/app/Support/DomainGlobeIcon.php @@ -0,0 +1,28 @@ +' + .'' + .''; + } + + public static function svg(string $class = 'h-5 w-5'): string + { + return sprintf( + '', + e($class), + self::VIEW_BOX, + self::paths() + ); + } +} diff --git a/app/Support/MailboxPricing.php b/app/Support/MailboxPricing.php new file mode 100644 index 0000000..1dd0176 --- /dev/null +++ b/app/Support/MailboxPricing.php @@ -0,0 +1,69 @@ + */ + public static function tiers(): array + { + return array_map(fn ($t) => [ + 'mb' => (int) $t['mb'], + 'price_minor' => (int) $t['price_minor'], + 'label' => self::label((int) $t['mb']), + ], (array) config('email.quota_tiers', [])); + } + + public static function isValidQuota(int $mb): bool + { + foreach (self::tiers() as $t) { + if ($t['mb'] === $mb) { + return true; + } + } + + return false; + } + + public static function priceMinorFor(int $mb): int + { + foreach (self::tiers() as $t) { + if ($t['mb'] === $mb) { + return $t['price_minor']; + } + } + + return 0; + } + + /** A tier is free when its monthly price is zero (the 1 GB plan). */ + public static function isFree(int $mb): bool + { + return self::priceMinorFor($mb) === 0; + } + + /** The smallest free tier's quota (the default for new mailboxes). */ + public static function freeQuotaMb(): int + { + foreach (self::tiers() as $t) { + if ($t['price_minor'] === 0) { + return $t['mb']; + } + } + + return self::tiers()[0]['mb'] ?? 1024; + } + + public static function defaultQuotaMb(): int + { + $default = (int) config('email.default_quota_mb', 1024); + + return self::isValidQuota($default) ? $default : self::freeQuotaMb(); + } + + public static function label(int $mb): string + { + return $mb % 1024 === 0 ? ($mb / 1024).' GB' : $mb.' MB'; + } +} diff --git a/app/Support/ResellerClubLegacy.php b/app/Support/ResellerClubLegacy.php new file mode 100644 index 0000000..44f6f96 --- /dev/null +++ b/app/Support/ResellerClubLegacy.php @@ -0,0 +1,122 @@ +order_type === RcServiceOrder::TYPE_RENEWAL) { + return true; + } + + return (bool) data_get($order->meta, 'is_renewal', false); + } + + public static function isRenewalDomainOrder(DomainOrder $order): bool + { + return $order->order_type === DomainOrder::TYPE_RENEWAL; + } + + public static function canAutomateFulfillment(RcServiceOrder $order): bool + { + if (! self::integrationEnabled()) { + return false; + } + + if (self::newOrdersEnabled()) { + return true; + } + + return self::renewalsEnabled() && self::isRenewalRcServiceOrder($order); + } + + /** + * @param Collection|iterable $items + */ + public static function assertCartCheckoutAllowed(iterable $items): void + { + $items = $items instanceof Collection ? $items : collect($items); + + if ($items->isEmpty()) { + throw new RuntimeException('Your cart is empty.'); + } + + if (self::newOrdersEnabled()) { + return; + } + + if (! self::renewalsEnabled()) { + throw new RuntimeException(self::renewalsDisabledMessage()); + } + + if ($items->contains(fn (RcServiceOrder $item) => ! self::isRenewalRcServiceOrder($item))) { + throw new RuntimeException( + 'ResellerClub checkout is limited to renewals for existing services. ' + .'New product purchases use our current domain and hosting products instead.' + ); + } + } + + public static function assertNewSaleAllowed(): void + { + if (self::newOrdersEnabled()) { + return; + } + + throw new RuntimeException( + 'ResellerClub is no longer available for new product purchases. ' + .'Use our domain and hosting products instead, or renew an existing ResellerClub service from your dashboard.' + ); + } + + public static function assertRenewalsAllowed(): void + { + if (! self::renewalsEnabled()) { + throw new RuntimeException(self::renewalsDisabledMessage()); + } + } + + /** @deprecated Use assertNewSaleAllowed() */ + public static function assertNewOrdersAllowed(): void + { + self::assertNewSaleAllowed(); + } + + public static function renewalsDisabledMessage(): string + { + return 'ResellerClub renewals are not available at this time. Please contact support if you need assistance.'; + } + + public static function fulfillmentDisabledMessage(): string + { + return 'Automated ResellerClub fulfillment for new purchases is no longer available. ' + .'Open a support ticket if you need help with a legacy order.'; + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php new file mode 100644 index 0000000..75163b0 --- /dev/null +++ b/app/Support/helpers.php @@ -0,0 +1,38 @@ +attributes->get('actingAccount') ?? $request->user(); + } +} + +if (! function_exists('ladill_domains_url')) { + function ladill_domains_url(string $path = ''): string + { + return 'https://'.config('app.domains_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); + } +} + +if (! function_exists('ladill_account_url')) { + function ladill_account_url(string $path = ''): string + { + return 'https://'.config('app.account_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); + } +} + +if (! function_exists('ladill_servers_url')) { + function ladill_servers_url(string $path = ''): string + { + return 'https://'.config('app.servers_domain').($path !== '' ? '/'.ltrim($path, '/') : ''); + } +} diff --git a/app/View/Composers/MailboxLinkComposer.php b/app/View/Composers/MailboxLinkComposer.php new file mode 100644 index 0000000..3b1308c --- /dev/null +++ b/app/View/Composers/MailboxLinkComposer.php @@ -0,0 +1,86 @@ + false]; + $dismissed = (bool) session('mailbox_link_banner_dismissed', false); + + if ($account?->public_id) { + $mailboxOptions = []; + + try { + $mailboxOptions = $this->mailboxes->forUser((string) $account->public_id); + } catch (\Throwable $e) { + report($e); + } + + try { + $status = $this->identity->mailboxLinkStatus((string) $account->public_id); + } catch (\Throwable $e) { + report($e); + $status = $this->localMailboxLinkStatus($account, $mailboxOptions); + } + + if (! ($status['show_reminder'] ?? false) && $this->localNeedsMailboxLink($account, $mailboxOptions, $status)) { + $status['show_reminder'] = true; + $status['stage'] = $status['stage'] ?? 'needs_link'; + $status['account_email'] = $status['account_email'] ?? $account->email; + } + } + + $view->with('mailboxLinkReminder', [ + ...$status, + 'visible' => ($status['show_reminder'] ?? false) && ! $dismissed, + ]); + } + + /** @param array> $mailboxOptions */ + private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $status): bool + { + if ($status['linked_mailbox'] ?? null) { + return false; + } + + $email = strtolower(trim($account->email ?? '')); + if ($email === '') { + return false; + } + + foreach ($mailboxOptions as $mailbox) { + if (strtolower((string) ($mailbox['address'] ?? '')) === $email) { + return false; + } + } + + return count($mailboxOptions) > 0; + } + + /** @param array> $mailboxOptions */ + /** @return array */ + private function localMailboxLinkStatus(User $account, array $mailboxOptions): array + { + $needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []); + + return [ + 'show_reminder' => $needsLink, + 'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox', + 'linked_mailbox' => null, + 'account_email' => $account->email, + ]; + } +} diff --git a/artisan b/artisan new file mode 100755 index 0000000..c35e31d --- /dev/null +++ b/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..80ef26f --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,26 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + \App\Http\Middleware\SetActingAccount::class, + ]); + + // External registrar webhook posts have no CSRF token. + $middleware->validateCsrfTokens(except: [ + 'webhooks/dynadot', + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:40:51+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:23:43+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.11.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.11.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-02T12:30:48+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-05-23T22:00:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.61.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/e8472ca9774452fe50841d9bdced060679f4d58d", + "reference": "e8472ca9774452fe50841d9bdced060679f4d58d", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.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": "^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": "^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.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/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.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^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).", + "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": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-04T14:22:52+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.18", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.18" + }, + "time": "2026-05-19T00:47:18+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.13", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-04-16T14:03:50+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.9-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-03-19T13:16:38+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.34.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" + }, + "time": "2026-05-14T10:28:08+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.11.4", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-04-07T09:57:54+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.4" + }, + "time": "2026-05-11T20:49:54+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.52", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-04-27T07:02:15+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.23", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" + }, + "time": "2026-05-23T13:41:31+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.2", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "8429c78ca35a09f27565311b98101e2826affde0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.2" + }, + "time": "2025-12-14T04:43:48+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "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": "^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": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "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-24T08:56:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "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|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/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/event-dispatcher", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/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/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.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": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "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-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "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": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.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": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "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": "^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": "^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": "^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" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "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-27T08:31:43+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff", + "reference": "5cefb712a25f320579615ba9e1942abaeade7dff", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "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": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "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": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:20:23+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "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": "<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": "^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.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "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-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.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-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.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-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.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-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.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-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "8339098cae28673c15cce00d80734af0453054e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", + "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\\Php83\\": "" + }, + "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.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/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-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.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "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.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-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "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-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "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": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "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-24T11:20:33+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "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" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/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-30T13:44:50+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "955e7815d677a3eaa7075231212f2110983adecc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.29.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.62.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-05-27T04:02:01+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.4", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.8 || ^8.0.8" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-04-21T14:04:20+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "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": "^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": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/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": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/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": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.55", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.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" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" + }, + "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" + } + ], + "time": "2026-02-18T12:37:06+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.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", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "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", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "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": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.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/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/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": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.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/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.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", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/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": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+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": "symfony/yaml", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.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-05-29T05:06:50+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/afia.php b/config/afia.php new file mode 100644 index 0000000..503b69f --- /dev/null +++ b/config/afia.php @@ -0,0 +1,10 @@ + env('AFIA_PRODUCT', 'hosting'), + 'enabled' => (bool) env('AFIA_ENABLED', true), + 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic + 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), + 'api_key' => env('AFIA_API_KEY'), +]; diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..9ab1f60 --- /dev/null +++ b/config/app.php @@ -0,0 +1,135 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + + // Platform identity surfaces (Zoho One model). This is the extracted Ladill + // Hosting app (hosting.ladill.com); it trusts the platform IdP for login. + 'platform_domain' => env('PLATFORM_DOMAIN', parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com'), + 'auth_domain' => env('AUTH_DOMAIN', 'auth.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'account_domain' => env('ACCOUNT_DOMAIN', 'account.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'hosting_domain' => env('HOSTING_DOMAIN', 'hosting.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + 'servers_domain' => env('SERVERS_DOMAIN', parse_url((string) env('APP_URL', 'https://servers.ladill.com'), PHP_URL_HOST) ?: 'servers.ladill.com'), + 'domains_domain' => env('DOMAINS_DOMAIN', 'domains.'.(parse_url((string) env('PLATFORM_URL', 'https://ladill.com'), PHP_URL_HOST) ?: 'ladill.com')), + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..1aa929b --- /dev/null +++ b/config/billing.php @@ -0,0 +1,14 @@ + env('BILLING_API_URL', 'https://ladill.com/api/billing'), + 'api_key' => env('BILLING_API_KEY_SERVERS'), + 'service' => 'servers', +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/domain.php b/config/domain.php new file mode 100644 index 0000000..cc99452 --- /dev/null +++ b/config/domain.php @@ -0,0 +1,6 @@ + env('DOMAIN_API_URL', 'https://ladill.com/api/domains'), + 'api_key' => env('DOMAIN_API_KEY_SERVERS'), +]; diff --git a/config/email.php b/config/email.php new file mode 100644 index 0000000..de2a4ea --- /dev/null +++ b/config/email.php @@ -0,0 +1,21 @@ + env('EMAIL_CURRENCY', 'GHS'), + 'default_quota_mb' => (int) env('EMAIL_DEFAULT_QUOTA_MB', 1024), // new mailboxes start on the free 1 GB tier + + // Storage tiers: quota (MB) → monthly price (minor units). 1 GB is free. + 'quota_tiers' => [ + ['mb' => 1024, 'price_minor' => 0], // 1 GB — Free forever + ['mb' => 5120, 'price_minor' => 1000], // 5 GB — GHS 10 + ['mb' => 10240, 'price_minor' => 2000], // 10 GB — GHS 20 + ['mb' => 25600, 'price_minor' => 3000], // 25 GB — GHS 30 + ['mb' => 51200, 'price_minor' => 6000], // 50 GB — GHS 60 + ], +]; diff --git a/config/emaildomain.php b/config/emaildomain.php new file mode 100644 index 0000000..69d5cd6 --- /dev/null +++ b/config/emaildomain.php @@ -0,0 +1,7 @@ + env('EMAILDOMAIN_API_URL', 'https://ladill.com/api/email-domains'), + 'api_key' => env('EMAILDOMAIN_API_KEY_EMAIL'), +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/hosting.php b/config/hosting.php new file mode 100644 index 0000000..a0b0375 --- /dev/null +++ b/config/hosting.php @@ -0,0 +1,703 @@ + [ + 'client_id' => env('CONTABO_CLIENT_ID'), + 'client_secret' => env('CONTABO_CLIENT_SECRET'), + 'api_user' => env('CONTABO_API_USER'), + 'api_password' => env('CONTABO_API_PASSWORD'), + 'product_catalog_endpoint' => env('CONTABO_PRODUCT_CATALOG_ENDPOINT'), + ], + + /* + |-------------------------------------------------------------------------- + | Pricing Configuration + |-------------------------------------------------------------------------- + | + | Dynamic pricing settings for VPS/Dedicated servers. + | Base prices from Contabo are in USD, converted to GHS using exchange rate. + | + */ + 'pricing' => [ + 'base_currency' => 'USD', + 'display_currency' => 'GHS', + + // Fallback rate used when live API fails + 'fallback_usd_to_ghs_rate' => env('FALLBACK_USD_TO_GHS_RATE', 15.50), + + // Profit margins (percentage on top of converted Contabo USD price) + 'margins' => [ + 'vps' => env('VPS_PROFIT_MARGIN', 45), + 'dedicated' => env('DEDICATED_PROFIT_MARGIN', 30), + ], + + 'contabo_price_cache_ttl' => env('CONTABO_PRICE_CACHE_TTL', 3600), + + 'term_discounts' => [ + 'quarterly' => 5, + 'semiannual' => 10, + 'yearly' => 20, + ], + + 'setup_fee_rules' => [ + // VPS 10 only — monthly and semiannual cycles carry a setup fee equal to 1× the monthly price + [ + 'product_ids' => ['V91'], + 'billing_cycles' => ['monthly', 'semiannual'], + 'monthly_price_multiplier' => 1, + 'label' => 'One-time setup fee', + ], + // Dedicated servers — fixed EUR fee that decreases with longer commitment; waived on yearly + [ + 'product_ids' => ['amd-ryzen-12-cores', 'amd-genoa-24-cores'], + 'billing_cycles' => ['monthly', 'quarterly', 'semiannual'], + 'fixed_eur_by_cycle' => [ + 'monthly' => 39.99, + 'quarterly' => 29.99, + 'semiannual' => 19.99, + ], + 'label' => 'One-time setup fee', + ], + ], + + // Fallback Contabo base prices in USD. Live API/feed prices are preferred. + // Keep these values as a fail-safe for API downtime. + 'contabo_base_prices' => [ + 'V91' => ['monthly' => 4.99, 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75], + 'V94' => ['monthly' => 7.00, 'name' => 'Cloud VPS 20 NVMe', 'cpu' => 6, 'ram_gb' => 12, 'disk_gb' => 100], + 'V97' => ['monthly' => 14.00, 'name' => 'Cloud VPS 30 NVMe', 'cpu' => 8, 'ram_gb' => 24, 'disk_gb' => 200], + 'V100' => ['monthly' => 25.00, 'name' => 'Cloud VPS 40 NVMe', 'cpu' => 12, 'ram_gb' => 48, 'disk_gb' => 250], + 'amd-ryzen-12-cores' => ['monthly' => 104.64, 'name' => 'AMD Ryzen 12 Cores', 'cpu' => 12, 'ram_gb' => 64, 'disk_gb' => 1000], + 'amd-genoa-24-cores' => ['monthly' => 184.21, 'name' => 'AMD Genoa 24 Cores', 'cpu' => 24, 'ram_gb' => 128, 'disk_gb' => 2000], + ], + ], + + /* + |-------------------------------------------------------------------------- + | Server Order Options + |-------------------------------------------------------------------------- + | + | Surcharges shown here are Contabo-side monthly USD add-on costs before + | the Ladill exchange-rate conversion and product margin are applied. + | Update these values whenever Contabo changes its commercial pricing. + | + */ + 'server_order' => [ + 'regions' => [ + 'EU' => ['label' => 'Europe (Germany)', 'monthly_usd' => 0.00, 'automated' => true], + 'US-central' => ['label' => 'US Central', 'monthly_usd' => 0.00, 'automated' => true], + 'US-east' => ['label' => 'US East', 'monthly_usd' => 0.00, 'automated' => true], + 'US-west' => ['label' => 'US West', 'monthly_usd' => 0.00, 'automated' => true], + 'UK' => ['label' => 'United Kingdom', 'monthly_usd' => 0.00, 'automated' => true], + 'SIN' => ['label' => 'Singapore', 'monthly_usd' => 0.00, 'automated' => true], + 'AUS' => ['label' => 'Australia', 'monthly_usd' => 0.00, 'automated' => true], + 'JPN' => ['label' => 'Japan', 'monthly_usd' => 0.00, 'automated' => true], + ], + 'image_pricing_rules' => [ + [ + 'key' => 'windows', + 'label' => 'Windows Server', + 'monthly_usd' => 9.30, + 'match' => ['windows'], + 'os_family' => 'windows', + 'default_user' => 'administrator', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'ubuntu', + 'label' => 'Ubuntu', + 'monthly_usd' => 0.00, + 'match' => ['ubuntu'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'rhel', + 'label' => 'RHEL Variants', + 'monthly_usd' => 0.00, + 'match' => ['alma', 'rocky', 'rhel', 'centos'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + [ + 'key' => 'custom', + 'label' => 'Custom Images', + 'monthly_usd' => 0.00, + 'custom_image' => true, + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => true, + 'automated' => true, + ], + [ + 'key' => 'linux', + 'label' => 'Linux', + 'monthly_usd' => 0.00, + 'match' => ['debian', 'fedora', 'linux', 'bsd'], + 'os_family' => 'linux', + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + ], + 'fallback_images' => [ + [ + 'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'label' => 'Ubuntu 22.04', + 'description' => 'Fallback Ubuntu image', + 'os_family' => 'linux', + 'monthly_usd' => 0.00, + 'default_user' => 'root', + 'requires_custom_image_addon' => false, + 'automated' => true, + ], + ], + 'managed_stack_supported_images' => [ + [ + 'value' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', + 'label' => 'Ubuntu 22.04 LTS', + 'match' => ['ubuntu 22.04'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Ubuntu 24.04 LTS', + 'match' => ['ubuntu 24.04'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Debian 12', + 'match' => ['debian 12', 'bookworm'], + 'os_family' => 'linux', + ], + [ + 'label' => 'AlmaLinux', + 'match' => ['almalinux', 'alma linux', 'alma'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Rocky Linux', + 'match' => ['rocky linux', 'rocky'], + 'os_family' => 'linux', + ], + [ + 'label' => 'Fedora', + 'match' => ['fedora'], + 'os_family' => 'linux', + ], + [ + 'label' => 'openSUSE Leap', + 'match' => ['opensuse leap', 'open suse leap', 'opensuse', 'leap'], + 'os_family' => 'linux', + ], + [ + 'label' => 'CentOS', + 'match' => ['centos'], + 'os_family' => 'linux', + ], + ], + 'licenses' => [ + 'none' => [ + 'label' => 'Remote Login Only', + 'description' => 'No hosting panel or commercial control-panel license.', + 'monthly_usd' => 0.00, + 'license' => null, + 'panel' => null, + 'automated' => true, + ], + 'ladill_panel' => [ + 'label' => 'Ladill Server Manager', + 'description' => 'Manage power, status, and server details from the Ladill server manager.', + 'monthly_usd' => 0.00, + 'license' => null, + 'panel' => 'ladill', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'requires_managed_stack_image' => true, + 'automated' => true, + ], + 'plesk_host' => [ + 'label' => 'Plesk + Linux', + 'description' => 'Plesk host edition on Linux.', + 'monthly_usd' => 15.00, + 'license' => 'PleskHost', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'plesk_windows' => [ + 'label' => 'Plesk + Windows', + 'description' => 'Plesk on Windows Server.', + 'monthly_usd' => 22.70, + 'license' => 'PleskHost', + 'compatible_os_families' => ['windows'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_5' => [ + 'label' => 'cPanel 5', + 'description' => 'cPanel license for up to 5 accounts.', + 'monthly_usd' => 35.99, + 'license' => 'cPanel5', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_30' => [ + 'label' => 'cPanel 30', + 'description' => 'cPanel license for up to 30 accounts.', + 'monthly_usd' => 53.99, + 'license' => 'cPanel30', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_50' => [ + 'label' => 'cPanel 50', + 'description' => 'cPanel license for up to 50 accounts.', + 'monthly_usd' => 53.99 + (0.49 * 20), + 'license' => 'cPanel50', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_100' => [ + 'label' => 'cPanel 100', + 'description' => 'cPanel license for up to 100 accounts.', + 'monthly_usd' => 69.99, + 'license' => 'cPanel100', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_150' => [ + 'label' => 'cPanel 150', + 'description' => 'cPanel license for up to 150 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 50), + 'license' => 'cPanel150', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_200' => [ + 'label' => 'cPanel 200', + 'description' => 'cPanel license for up to 200 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 100), + 'license' => 'cPanel200', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_250' => [ + 'label' => 'cPanel 250', + 'description' => 'cPanel license for up to 250 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 150), + 'license' => 'cPanel250', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_300' => [ + 'label' => 'cPanel 300', + 'description' => 'cPanel license for up to 300 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 200), + 'license' => 'cPanel300', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_350' => [ + 'label' => 'cPanel 350', + 'description' => 'cPanel license for up to 350 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 250), + 'license' => 'cPanel350', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_400' => [ + 'label' => 'cPanel 400', + 'description' => 'cPanel license for up to 400 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 300), + 'license' => 'cPanel400', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_450' => [ + 'label' => 'cPanel 450', + 'description' => 'cPanel license for up to 450 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 350), + 'license' => 'cPanel450', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_500' => [ + 'label' => 'cPanel 500', + 'description' => 'cPanel license for up to 500 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 400), + 'license' => 'cPanel500', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_550' => [ + 'label' => 'cPanel 550', + 'description' => 'cPanel license for up to 550 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 450), + 'license' => 'cPanel550', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_600' => [ + 'label' => 'cPanel 600', + 'description' => 'cPanel license for up to 600 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 500), + 'license' => 'cPanel600', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_650' => [ + 'label' => 'cPanel 650', + 'description' => 'cPanel license for up to 650 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 550), + 'license' => 'cPanel650', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_700' => [ + 'label' => 'cPanel 700', + 'description' => 'cPanel license for up to 700 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 600), + 'license' => 'cPanel700', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_750' => [ + 'label' => 'cPanel 750', + 'description' => 'cPanel license for up to 750 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 650), + 'license' => 'cPanel750', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_800' => [ + 'label' => 'cPanel 800', + 'description' => 'cPanel license for up to 800 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 700), + 'license' => 'cPanel800', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_850' => [ + 'label' => 'cPanel 850', + 'description' => 'cPanel license for up to 850 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 750), + 'license' => 'cPanel850', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_900' => [ + 'label' => 'cPanel 900', + 'description' => 'cPanel license for up to 900 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 800), + 'license' => 'cPanel900', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_950' => [ + 'label' => 'cPanel 950', + 'description' => 'cPanel license for up to 950 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 850), + 'license' => 'cPanel950', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'cpanel_1000' => [ + 'label' => 'cPanel 1000', + 'description' => 'cPanel license for up to 1000 accounts.', + 'monthly_usd' => 69.99 + (0.49 * 900), + 'license' => 'cPanel1000', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + ], + 'applications' => [ + 'none' => [ + 'label' => 'No Preinstalled App', + 'description' => 'Provision the server without an extra preinstalled application.', + 'monthly_usd' => 0.00, + 'automated' => true, + ], + 'webmin' => [ + 'label' => 'Webmin', + 'description' => 'Free Webmin server panel installed automatically on Linux.', + 'monthly_usd' => 0.00, + 'cloud_init_preset' => 'webmin', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'webmin_lamp' => [ + 'label' => 'Webmin + LAMP', + 'description' => 'Free Webmin plus Apache, MariaDB, and PHP on Linux.', + 'monthly_usd' => 0.00, + 'cloud_init_preset' => 'webmin_lamp', + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'ipfs_node' => [ + 'label' => 'IPFS Node', + 'description' => 'Contabo application profile for IPFS nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_IPFS_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'flux_node' => [ + 'label' => 'Flux Node', + 'description' => 'Contabo application profile for Flux nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_FLUX_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'horizon_node' => [ + 'label' => 'Horizen Node', + 'description' => 'Contabo application profile for Horizen nodes.', + 'monthly_usd' => 0.00, + 'application_id' => env('CONTABO_APP_ID_HORIZON_NODE'), + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'ethereum_node' => [ + 'label' => 'Ethereum Node', + 'description' => 'Contabo application profile for Ethereum 2.0 nodes.', + 'monthly_usd' => 0.00, + 'application_id' => null, + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + 'bitcoin_node' => [ + 'label' => 'Bitcoin Full Node', + 'description' => 'Contabo application profile for Bitcoin full nodes.', + 'monthly_usd' => 0.00, + 'application_id' => null, + 'compatible_os_families' => ['linux'], + 'requires_image' => true, + 'automated' => true, + ], + ], + 'additional_ip' => [ + 'none' => [ + 'label' => '1 IP Address', + 'description' => 'Default primary IP only.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'one_extra' => [ + 'label' => '1 Additional IP', + 'description' => 'Adds one extra IPv4 address.', + 'monthly_usd' => 4.50, + 'add_on' => 'additionalIps', + 'automated' => true, + ], + ], + 'private_networking' => [ + 'disabled' => [ + 'label' => 'No Private Networking', + 'description' => 'Do not enable the private networking add-on.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'enabled' => [ + 'label' => 'Private Networking Enabled', + 'description' => 'Purchases the private networking add-on.', + 'monthly_usd' => 2.99, + 'add_on' => 'privateNetworking', + 'automated' => true, + ], + ], + 'storage_types' => [ + 'included' => [ + 'label' => 'Included Storage', + 'description' => 'Use the plan default storage.', + 'monthly_usd' => 0.00, + 'add_on' => null, + 'automated' => true, + ], + 'ssd_300' => [ + 'label' => '300 GB SSD', + 'description' => 'Higher SSD storage tier.', + 'monthly_usd' => 1.95, + 'add_on' => 'extraStorage', + 'automated' => false, + ], + 'nvme_150' => [ + 'label' => '150 GB NVMe', + 'description' => 'Higher NVMe storage tier.', + 'monthly_usd' => 2.30, + 'add_on' => 'extraStorage', + 'automated' => false, + ], + ], + 'object_storage' => [ + 'none' => [ + 'label' => 'No Object Storage', + 'description' => 'Do not order object storage with this server.', + 'monthly_usd' => 0.00, + 'automated' => false, + ], + 'eu_250' => [ + 'label' => '250 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 2.99, + 'automated' => false, + ], + 'eu_500' => [ + 'label' => '500 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 5.98, + 'automated' => false, + ], + 'eu_750' => [ + 'label' => '750 GB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 8.97, + 'automated' => false, + ], + 'eu_1024' => [ + 'label' => '1 TB Object Storage (EU)', + 'description' => 'S3-compatible object storage in the European Union.', + 'monthly_usd' => 11.96, + 'automated' => false, + ], + ], + 'linux_default_users' => [ + 'root' => ['label' => 'root'], + 'admin' => ['label' => 'admin'], + ], + 'windows_default_users' => [ + 'admin' => ['label' => 'admin'], + 'administrator' => ['label' => 'administrator'], + ], + ], + + 'server_agent' => [ + 'release_version' => env('SERVER_AGENT_RELEASE_VERSION', '0.2.0'), + 'signed_release_ttl_minutes' => (int) env('SERVER_AGENT_SIGNED_RELEASE_TTL_MINUTES', 10080), + 'heartbeat_interval_seconds' => 15, + ], + + /* + |-------------------------------------------------------------------------- + | Shared Hosting Configuration + |-------------------------------------------------------------------------- + | + | Settings for the shared hosting nodes. + | + */ + 'shared' => [ + 'default_php_version' => '8.2', + 'available_php_versions' => ['8.0', '8.1', '8.2', '8.3'], + 'default_document_root' => 'public_html', + 'max_upload_size_mb' => 64, + 'max_execution_time' => 300, + 'memory_limit_mb' => 256, + 'phpmyadmin_url' => env('HOSTING_PHPMYADMIN_URL', ''), + 'phpmyadmin_sso_secret' => env('HOSTING_PHPMYADMIN_SSO_SECRET', ''), + ], + + /* + |-------------------------------------------------------------------------- + | VPS Configuration + |-------------------------------------------------------------------------- + | + | Default settings for VPS instances. + | + */ + 'vps' => [ + 'default_region' => 'EU', + 'default_image' => 'afecbb85-e2fc-46f0-9684-b46b1faf00bb', // Ubuntu 22.04 + 'available_regions' => ['EU', 'US-central', 'US-east', 'US-west', 'SIN', 'UK', 'AUS', 'JPN'], + ], + + /* + |-------------------------------------------------------------------------- + | App Installer Configuration + |-------------------------------------------------------------------------- + | + | Settings for the one-click app installer. + | + */ + 'apps' => [ + 'enabled' => ['wordpress', 'joomla', 'drupal', 'opencart'], + 'magento_enabled' => false, // Requires higher resource plans + 'wordpress' => [ + 'default_version' => '6.4', + 'wp_cli_path' => '/usr/local/bin/wp', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Provisioning Settings + |-------------------------------------------------------------------------- + | + | General provisioning configuration. + | + */ + 'provisioning' => [ + 'max_retries' => 3, + 'retry_delay_minutes' => 5, + 'timeout_minutes' => 30, + ], + + /* + |-------------------------------------------------------------------------- + | Legacy ResellerClub Integration + |-------------------------------------------------------------------------- + | + | Keep RC integration active for existing customers. + | + */ + 'legacy' => [ + // Keep RC data/API available for customers with existing ResellerClub services. + 'rc_enabled' => (bool) env('LADILL_RC_LEGACY_ENABLED', true), + // New sales and automated fulfillment use Ladill (Dynadot domains, native hosting). + 'rc_new_orders_enabled' => (bool) env('LADILL_RC_NEW_ORDERS_ENABLED', false), + // Renew existing ResellerClub services (hosting, domains, VPS, etc.). + 'rc_renewals_enabled' => (bool) env('LADILL_RC_RENEWALS_ENABLED', true), + ], +]; diff --git a/config/identity.php b/config/identity.php new file mode 100644 index 0000000..17c9719 --- /dev/null +++ b/config/identity.php @@ -0,0 +1,6 @@ + env('IDENTITY_API_URL', 'https://ladill.com/api'), + 'api_key' => env('IDENTITY_API_KEY_SERVERS'), +]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php new file mode 100644 index 0000000..304896d --- /dev/null +++ b/config/ladill_launcher.php @@ -0,0 +1,34 @@ +. +*/ + +$root = config('app.platform_domain', 'ladill.com'); + +return [ + 'apps' => [ + ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], + ['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'], + ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], + ['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'], + ['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'], + ['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'], + ], +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..b09cb25 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/mailbox.php b/config/mailbox.php new file mode 100644 index 0000000..14a403c --- /dev/null +++ b/config/mailbox.php @@ -0,0 +1,9 @@ + env('MAILBOX_API_URL', 'https://ladill.com/api/mailboxes'), + 'api_key' => env('MAILBOX_API_KEY_EMAIL'), +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..f62219d --- /dev/null +++ b/config/services.php @@ -0,0 +1,62 @@ + [ + 'issuer' => 'https://'.config('app.auth_domain'), + 'client_id' => env('LADILL_SSO_CLIENT_ID'), + 'client_secret' => env('LADILL_SSO_CLIENT_SECRET'), + 'redirect' => rtrim((string) env('APP_URL', 'https://bird.ladill.com'), '/').'/sso/callback', + ], + + 'ladill_webmail' => [ + 'url' => env('LADILL_WEBMAIL_URL', 'https://mail.ladill.com'), + ], + + 'postmark' => [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + // Paystack — guest checkout (and the card fallback for logged-in buyers). + 'paystack' => [ + 'base_url' => env('PAYSTACK_BASE_URL', 'https://api.paystack.co'), + 'public_key' => env('PAYSTACK_PUBLIC_KEY'), + 'secret_key' => env('PAYSTACK_SECRET_KEY'), + 'webhook_secret' => env('PAYSTACK_WEBHOOK_SECRET', env('PAYSTACK_SECRET_KEY')), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..a138076 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,52 @@ +id(); + $table->uuid('public_id')->unique(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->string('avatar_url')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password')->nullable(); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_03_17_120000_create_domains_table.php b/database/migrations/2026_03_17_120000_create_domains_table.php new file mode 100644 index 0000000..232dded --- /dev/null +++ b/database/migrations/2026_03_17_120000_create_domains_table.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('website_id')->nullable(); + $table->unsignedBigInteger('hosting_account_id')->nullable(); + $table->unsignedBigInteger('email_domain_id')->nullable(); + $table->string('host')->unique(); + $table->string('type')->default('custom'); + $table->string('source')->default('hosting'); + $table->string('onboarding_mode')->default('ns_auto'); + $table->string('verification_token')->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->string('status')->default('pending'); + $table->string('ssl_status')->default('pending'); + $table->string('onboarding_state')->default('pending_ns'); + $table->string('dns_mode')->nullable(); + $table->json('ns_expected')->nullable(); + $table->json('ns_observed')->nullable(); + $table->timestamp('ns_checked_at')->nullable(); + $table->timestamp('connected_at')->nullable(); + $table->timestamp('mail_ready_at')->nullable(); + $table->timestamp('active_at')->nullable(); + $table->timestamp('manual_dns_verified_at')->nullable(); + $table->json('verification_meta')->nullable(); + $table->timestamp('ssl_provisioned_at')->nullable(); + $table->timestamp('ssl_expires_at')->nullable(); + $table->string('registrar')->nullable(); + $table->string('registrar_order_id')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'host']); + $table->index(['source']); + $table->index(['hosting_account_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('domains'); + } +}; diff --git a/database/migrations/2026_03_18_140000_create_hosting_orders_table.php b/database/migrations/2026_03_18_140000_create_hosting_orders_table.php new file mode 100644 index 0000000..edcf124 --- /dev/null +++ b/database/migrations/2026_03_18_140000_create_hosting_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete(); + $table->string('domain_name'); + $table->string('rc_order_id')->nullable()->index(); + $table->string('rc_customer_id')->nullable(); + $table->string('plan_name')->nullable(); + $table->string('hosting_type')->default('single_domain'); + $table->string('status')->default('pending'); + $table->string('ip_address')->nullable(); + $table->string('cpanel_url')->nullable(); + $table->string('cpanel_username')->nullable(); + $table->unsignedInteger('bandwidth_mb')->nullable(); + $table->unsignedInteger('disk_space_mb')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('provisioned_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('hosting_orders'); + } +}; diff --git a/database/migrations/2026_03_18_220000_create_rc_service_orders_table.php b/database/migrations/2026_03_18_220000_create_rc_service_orders_table.php new file mode 100644 index 0000000..26f7af2 --- /dev/null +++ b/database/migrations/2026_03_18_220000_create_rc_service_orders_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('category'); + $table->string('domain_name'); + $table->string('rc_order_id')->nullable()->index(); + $table->string('rc_customer_id')->nullable(); + $table->string('plan_id')->nullable(); + $table->string('plan_name')->nullable(); + $table->string('status')->default('pending'); + $table->string('server_location')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('access_url')->nullable(); + $table->string('access_username')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('provisioned_at')->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('rc_service_orders'); + } +}; diff --git a/database/migrations/2026_03_19_120000_add_checkout_fields_to_rc_service_orders_table.php b/database/migrations/2026_03_19_120000_add_checkout_fields_to_rc_service_orders_table.php new file mode 100644 index 0000000..b486692 --- /dev/null +++ b/database/migrations/2026_03_19_120000_add_checkout_fields_to_rc_service_orders_table.php @@ -0,0 +1,74 @@ +string('order_type')->default('service')->after('category')->index(); + $table->string('payment_status')->default('unpaid')->after('status')->index(); + $table->string('fulfillment_status')->default('cart')->after('payment_status')->index(); + $table->string('payment_reference')->nullable()->after('fulfillment_status')->index(); + $table->unsignedInteger('amount_minor')->nullable()->after('payment_reference'); + $table->string('currency', 3)->default('GHS')->after('amount_minor'); + $table->unsignedSmallInteger('term_months')->nullable()->after('currency'); + $table->unsignedSmallInteger('term_years')->nullable()->after('term_months'); + $table->timestamp('paid_at')->nullable()->after('term_years'); + $table->timestamp('submitted_at')->nullable()->after('paid_at'); + $table->timestamp('last_synced_at')->nullable()->after('submitted_at'); + }); + + DB::table('rc_service_orders') + ->orderBy('id') + ->chunkById(200, function ($rows): void { + foreach ($rows as $row) { + $status = strtolower((string) ($row->status ?? 'pending')); + + DB::table('rc_service_orders') + ->where('id', (int) $row->id) + ->update([ + 'order_type' => in_array((string) ($row->category ?? ''), ['domain-registration', 'domain-transfer'], true) + ? ((string) $row->category === 'domain-transfer' ? 'domain_transfer' : 'domain_registration') + : 'service', + 'payment_status' => 'paid', + 'fulfillment_status' => match ($status) { + 'active', 'suspended', 'cancelled', 'expired' => 'fulfilled', + 'failed' => 'failed', + default => 'awaiting_vendor', + }, + 'currency' => 'GHS', + 'submitted_at' => $row->created_at, + 'last_synced_at' => $row->updated_at, + ]); + } + }); + } + + public function down(): void + { + Schema::table('rc_service_orders', function (Blueprint $table) { + $table->dropIndex(['order_type']); + $table->dropIndex(['payment_status']); + $table->dropIndex(['fulfillment_status']); + $table->dropIndex(['payment_reference']); + $table->dropColumn([ + 'order_type', + 'payment_status', + 'fulfillment_status', + 'payment_reference', + 'amount_minor', + 'currency', + 'term_months', + 'term_years', + 'paid_at', + 'submitted_at', + 'last_synced_at', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_24_081519_create_hosting_nodes_table.php b/database/migrations/2026_03_24_081519_create_hosting_nodes_table.php new file mode 100644 index 0000000..c8d44eb --- /dev/null +++ b/database/migrations/2026_03_24_081519_create_hosting_nodes_table.php @@ -0,0 +1,245 @@ +id(); + $table->string('name'); + $table->string('hostname'); + $table->string('ip_address'); + $table->string('ipv6_address')->nullable(); + $table->enum('type', ['shared', 'vps', 'dedicated'])->default('shared'); + $table->enum('provider', ['contabo', 'manual', 'legacy_rc', 'local'])->default('contabo'); + $table->string('provider_instance_id')->nullable(); + $table->string('region')->nullable(); + $table->string('datacenter')->nullable(); + $table->unsignedInteger('cpu_cores')->nullable(); + $table->unsignedInteger('ram_mb')->nullable(); + $table->unsignedInteger('disk_gb')->nullable(); + $table->unsignedInteger('bandwidth_tb')->nullable(); + $table->unsignedInteger('max_accounts')->nullable(); + $table->unsignedInteger('current_accounts')->default(0); + $table->enum('status', ['provisioning', 'active', 'maintenance', 'full', 'decommissioned'])->default('provisioning'); + $table->json('features')->nullable(); + $table->json('installed_software')->nullable(); + $table->string('ssh_port')->default('22'); + $table->text('ssh_private_key')->nullable(); + $table->string('control_panel')->nullable(); + $table->timestamp('last_health_check_at')->nullable(); + $table->json('health_status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['type', 'status']); + $table->index('provider_instance_id'); + }); + + // Hosting Plans - Product offerings + Schema::create('hosting_plans', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->enum('type', ['shared', 'vps', 'dedicated', 'email'])->default('shared'); + $table->decimal('price_monthly', 10, 2); + $table->decimal('price_yearly', 10, 2)->nullable(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('disk_gb'); + $table->unsignedInteger('bandwidth_gb')->nullable(); + $table->unsignedInteger('cpu_cores')->nullable(); + $table->unsignedInteger('ram_mb')->nullable(); + $table->unsignedInteger('max_domains')->nullable(); + $table->unsignedInteger('max_databases')->nullable(); + $table->unsignedInteger('max_email_accounts')->nullable(); + $table->unsignedInteger('max_ftp_accounts')->nullable(); + $table->boolean('ssl_included')->default(true); + $table->boolean('backups_included')->default(true); + $table->unsignedInteger('backup_retention_days')->default(7); + $table->json('features')->nullable(); + $table->json('php_versions')->nullable(); + $table->boolean('is_active')->default(true); + $table->boolean('is_featured')->default(false); + $table->unsignedInteger('sort_order')->default(0); + $table->string('contabo_product_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['type', 'is_active']); + }); + + // Hosting Accounts - Customer hosting instances + Schema::create('hosting_accounts', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosting_plan_id')->constrained()->restrictOnDelete(); + $table->foreignId('hosting_node_id')->nullable()->constrained()->nullOnDelete(); + $table->string('username', 32)->unique(); + $table->string('primary_domain')->nullable(); + $table->enum('type', ['shared', 'vps', 'dedicated'])->default('shared'); + $table->enum('status', ['pending', 'provisioning', 'active', 'suspended', 'terminated', 'failed'])->default('pending'); + $table->string('provider_account_id')->nullable(); + $table->string('home_directory')->nullable(); + $table->unsignedBigInteger('disk_used_bytes')->default(0); + $table->unsignedBigInteger('bandwidth_used_bytes')->default(0); + $table->timestamp('bandwidth_reset_at')->nullable(); + $table->string('php_version')->default('8.2'); + $table->json('features_enabled')->nullable(); + $table->json('resource_limits')->nullable(); + $table->text('suspension_reason')->nullable(); + $table->timestamp('suspended_at')->nullable(); + $table->timestamp('provisioned_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'status']); + $table->index('hosting_node_id'); + $table->index('primary_domain'); + }); + + // Hosted Sites - Individual websites on hosting accounts + Schema::create('hosted_sites', function (Blueprint $table) { + $table->id(); + $table->foreignId('hosting_account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('domain_id')->nullable()->constrained()->nullOnDelete(); + $table->string('domain'); + $table->string('document_root'); + $table->enum('type', ['primary', 'addon', 'subdomain', 'parked'])->default('addon'); + $table->enum('status', ['pending', 'active', 'suspended', 'disabled'])->default('pending'); + $table->string('php_version')->nullable(); + $table->boolean('ssl_enabled')->default(false); + $table->timestamp('ssl_expires_at')->nullable(); + $table->string('installed_app')->nullable(); + $table->string('installed_app_version')->nullable(); + $table->json('app_config')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['hosting_account_id', 'domain']); + $table->index('domain'); + }); + + // Hosted Databases + Schema::create('hosted_databases', function (Blueprint $table) { + $table->id(); + $table->foreignId('hosting_account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosted_site_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name'); + $table->string('username'); + $table->string('password_encrypted')->nullable(); + $table->enum('type', ['mysql', 'mariadb', 'postgresql'])->default('mysql'); + $table->unsignedBigInteger('size_bytes')->default(0); + $table->enum('status', ['active', 'suspended', 'deleted'])->default('active'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['hosting_account_id', 'name']); + }); + + // Provisioning Jobs - Track async provisioning operations + Schema::create('provisioning_jobs', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('type'); + $table->morphs('provisionable'); + $table->enum('provider', ['contabo', 'shared_node', 'mail', 'dns', 'ssl']); + $table->enum('status', ['pending', 'running', 'completed', 'failed', 'cancelled'])->default('pending'); + $table->json('payload')->nullable(); + $table->json('result')->nullable(); + $table->text('error_message')->nullable(); + $table->unsignedInteger('attempts')->default(0); + $table->unsignedInteger('max_attempts')->default(3); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('next_retry_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'next_retry_at']); + }); + + // VPS Instances - For managed VPS products + Schema::create('vps_instances', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosting_plan_id')->constrained()->restrictOnDelete(); + $table->string('name'); + $table->string('hostname'); + $table->string('provider')->default('contabo'); + $table->string('provider_instance_id')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('ipv6_address')->nullable(); + $table->string('region')->nullable(); + $table->string('datacenter')->nullable(); + $table->string('image')->nullable(); + $table->unsignedInteger('cpu_cores')->nullable(); + $table->unsignedInteger('ram_mb')->nullable(); + $table->unsignedInteger('disk_gb')->nullable(); + $table->enum('status', ['pending', 'provisioning', 'running', 'stopped', 'suspended', 'terminated', 'failed'])->default('pending'); + $table->enum('power_status', ['on', 'off', 'unknown'])->default('unknown'); + $table->string('root_password_encrypted')->nullable(); + $table->text('ssh_public_key')->nullable(); + $table->json('tags')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('provisioned_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'status']); + $table->index('provider_instance_id'); + }); + + // VPS Snapshots + Schema::create('vps_snapshots', function (Blueprint $table) { + $table->id(); + $table->foreignId('vps_instance_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('provider_snapshot_id')->nullable(); + $table->unsignedBigInteger('size_bytes')->nullable(); + $table->enum('status', ['creating', 'available', 'deleting', 'failed'])->default('creating'); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index('provider_snapshot_id'); + }); + + // App Installations - Track installed applications + Schema::create('app_installations', function (Blueprint $table) { + $table->id(); + $table->foreignId('hosted_site_id')->constrained()->cascadeOnDelete(); + $table->string('app_type'); + $table->string('app_version'); + $table->string('admin_path')->nullable(); + $table->string('admin_username')->nullable(); + $table->string('admin_email')->nullable(); + $table->json('config')->nullable(); + $table->enum('status', ['installing', 'active', 'updating', 'failed', 'removed'])->default('installing'); + $table->timestamp('installed_at')->nullable(); + $table->timestamp('last_updated_at')->nullable(); + $table->timestamps(); + + $table->index(['app_type', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('app_installations'); + Schema::dropIfExists('vps_snapshots'); + Schema::dropIfExists('vps_instances'); + Schema::dropIfExists('provisioning_jobs'); + Schema::dropIfExists('hosted_databases'); + Schema::dropIfExists('hosted_sites'); + Schema::dropIfExists('hosting_accounts'); + Schema::dropIfExists('hosting_plans'); + Schema::dropIfExists('hosting_nodes'); + } +}; diff --git a/database/migrations/2026_03_24_121800_create_hosting_platform_tables.php b/database/migrations/2026_03_24_121800_create_hosting_platform_tables.php new file mode 100644 index 0000000..f383a86 --- /dev/null +++ b/database/migrations/2026_03_24_121800_create_hosting_platform_tables.php @@ -0,0 +1,174 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->enum('category', ['shared', 'vps', 'dedicated', 'email']); + $table->enum('type', ['single_domain', 'multi_domain', 'wordpress', 'vps', 'dedicated', 'email_standalone']); + $table->decimal('price_monthly', 10, 2); + $table->decimal('price_quarterly', 10, 2)->nullable(); + $table->decimal('price_yearly', 10, 2)->nullable(); + $table->decimal('price_biennial', 10, 2)->nullable(); + $table->decimal('setup_fee', 10, 2)->default(0); + $table->string('currency', 3)->default('USD'); + + // Resource limits + $table->integer('disk_gb')->nullable(); + $table->integer('bandwidth_gb')->nullable(); + $table->integer('cpu_cores')->nullable(); + $table->integer('ram_mb')->nullable(); + $table->integer('max_domains')->default(1); + $table->integer('max_databases')->nullable(); + $table->integer('max_email_accounts')->nullable(); + $table->integer('max_ftp_accounts')->nullable(); + + // Features + $table->boolean('ssl_included')->default(true); + $table->boolean('backups_included')->default(false); + $table->integer('backup_retention_days')->nullable(); + $table->json('features')->nullable(); + $table->json('php_versions')->nullable(); + + // Contabo mapping for VPS/Dedicated + $table->string('contabo_product_id')->nullable(); + $table->string('contabo_region')->nullable(); + + // Display + $table->boolean('is_active')->default(true); + $table->boolean('is_featured')->default(false); + $table->boolean('is_visible')->default(true); + $table->integer('sort_order')->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->index(['category', 'is_active']); + $table->index(['type', 'is_active']); + }); + + // Customer Hosting Orders - New unified order system + Schema::create('customer_hosting_orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('hosting_product_id')->constrained()->onDelete('restrict'); + $table->foreignId('domain_id')->nullable()->constrained()->onDelete('set null'); + + $table->string('domain_name'); + $table->enum('billing_cycle', ['monthly', 'quarterly', 'yearly', 'biennial'])->default('yearly'); + $table->decimal('amount_paid', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->string('payment_reference')->nullable(); + + // Order status with approval workflow + $table->enum('status', [ + 'pending_payment', + 'pending_approval', + 'approved', + 'provisioning', + 'active', + 'suspended', + 'cancelled', + 'expired', + 'failed' + ])->default('pending_payment'); + + // Approval tracking + $table->foreignId('approved_by')->nullable()->constrained('users')->onDelete('set null'); + $table->timestamp('approved_at')->nullable(); + $table->text('approval_notes')->nullable(); + + // Provisioning details + $table->foreignId('hosting_node_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('hosting_account_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('vps_instance_id')->nullable()->constrained()->onDelete('set null'); + + $table->string('server_username')->nullable(); + $table->string('server_ip')->nullable(); + $table->string('control_panel_url')->nullable(); + + $table->timestamp('provisioned_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('suspended_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + + $table->json('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'status']); + $table->index(['status', 'created_at']); + $table->index('domain_name'); + }); + + // Node Capacity Alerts - Track when new shared hosting nodes are needed + Schema::create('node_capacity_alerts', function (Blueprint $table) { + $table->id(); + $table->foreignId('hosting_node_id')->constrained()->onDelete('cascade'); + $table->enum('alert_type', ['capacity_warning', 'capacity_critical', 'resource_high', 'needs_expansion']); + $table->integer('current_accounts'); + $table->integer('max_accounts'); + $table->decimal('capacity_percent', 5, 2); + $table->json('resource_usage')->nullable(); + $table->text('message'); + $table->boolean('is_resolved')->default(false); + $table->foreignId('resolved_by')->nullable()->constrained('users')->onDelete('set null'); + $table->timestamp('resolved_at')->nullable(); + $table->text('resolution_notes')->nullable(); + $table->timestamps(); + + $table->index(['is_resolved', 'created_at']); + $table->index(['hosting_node_id', 'is_resolved']); + }); + + // Provisioning Queue - Track all provisioning jobs + Schema::create('provisioning_queue', function (Blueprint $table) { + $table->id(); + $table->foreignId('customer_hosting_order_id')->constrained()->onDelete('cascade'); + $table->enum('status', ['queued', 'processing', 'completed', 'failed', 'cancelled'])->default('queued'); + $table->enum('provider', ['shared_node', 'contabo_vps', 'contabo_dedicated']); + $table->integer('attempts')->default(0); + $table->integer('max_attempts')->default(3); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->json('provisioning_log')->nullable(); + $table->timestamps(); + + $table->index(['status', 'created_at']); + }); + + // Add approval fields to existing hosting_orders table for legacy RC orders + if (Schema::hasTable('hosting_orders')) { + Schema::table('hosting_orders', function (Blueprint $table) { + if (!Schema::hasColumn('hosting_orders', 'is_legacy_rc')) { + $table->boolean('is_legacy_rc')->default(true)->after('meta'); + } + }); + } + } + + public function down(): void + { + Schema::dropIfExists('provisioning_queue'); + Schema::dropIfExists('node_capacity_alerts'); + Schema::dropIfExists('customer_hosting_orders'); + Schema::dropIfExists('hosting_products'); + + if (Schema::hasTable('hosting_orders') && Schema::hasColumn('hosting_orders', 'is_legacy_rc')) { + Schema::table('hosting_orders', function (Blueprint $table) { + $table->dropColumn('is_legacy_rc'); + }); + } + } +}; diff --git a/database/migrations/2026_03_26_201008_add_hosting_product_id_to_hosting_accounts_table.php b/database/migrations/2026_03_26_201008_add_hosting_product_id_to_hosting_accounts_table.php new file mode 100644 index 0000000..398416f --- /dev/null +++ b/database/migrations/2026_03_26_201008_add_hosting_product_id_to_hosting_accounts_table.php @@ -0,0 +1,29 @@ +foreignId('hosting_product_id')->nullable()->after('user_id')->constrained()->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('hosting_accounts', function (Blueprint $table) { + $table->dropForeign(['hosting_product_id']); + $table->dropColumn('hosting_product_id'); + }); + } +}; diff --git a/database/migrations/2026_03_26_201149_make_hosting_plan_id_nullable_in_hosting_accounts_table.php b/database/migrations/2026_03_26_201149_make_hosting_plan_id_nullable_in_hosting_accounts_table.php new file mode 100644 index 0000000..c950b11 --- /dev/null +++ b/database/migrations/2026_03_26_201149_make_hosting_plan_id_nullable_in_hosting_accounts_table.php @@ -0,0 +1,29 @@ +dropForeign(['hosting_plan_id']); + $table->dropColumn('hosting_plan_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('hosting_accounts', function (Blueprint $table) { + $table->foreignId('hosting_plan_id')->nullable()->after('hosting_product_id')->constrained()->onDelete('cascade'); + }); + } +}; diff --git a/database/migrations/2026_03_29_111141_add_encrypted_credentials_to_app_installations_table.php b/database/migrations/2026_03_29_111141_add_encrypted_credentials_to_app_installations_table.php new file mode 100644 index 0000000..9431a1e --- /dev/null +++ b/database/migrations/2026_03_29_111141_add_encrypted_credentials_to_app_installations_table.php @@ -0,0 +1,35 @@ +text('admin_password_encrypted')->nullable()->after('admin_email'); + $table->string('database_name')->nullable()->after('admin_password_encrypted'); + $table->string('database_username')->nullable()->after('database_name'); + $table->text('database_password_encrypted')->nullable()->after('database_username'); + $table->string('install_url')->nullable()->after('database_password_encrypted'); + }); + } + + public function down(): void + { + Schema::table('app_installations', function (Blueprint $table) { + $table->dropColumn([ + 'admin_password_encrypted', + 'database_name', + 'database_username', + 'database_password_encrypted', + 'install_url', + ]); + }); + } +}; diff --git a/database/migrations/2026_03_29_154900_add_progress_tracking_to_app_installations.php b/database/migrations/2026_03_29_154900_add_progress_tracking_to_app_installations.php new file mode 100644 index 0000000..a6c29f9 --- /dev/null +++ b/database/migrations/2026_03_29_154900_add_progress_tracking_to_app_installations.php @@ -0,0 +1,26 @@ +unsignedTinyInteger('progress')->default(0)->after('status'); + $table->string('progress_message')->nullable()->after('progress'); + $table->string('current_step')->nullable()->after('progress_message'); + $table->unsignedTinyInteger('total_steps')->default(5)->after('current_step'); + $table->text('error_message')->nullable()->after('total_steps'); + }); + } + + public function down(): void + { + Schema::table('app_installations', function (Blueprint $table) { + $table->dropColumn(['progress', 'progress_message', 'current_step', 'total_steps', 'error_message']); + }); + } +}; diff --git a/database/migrations/2026_04_02_121500_change_customer_hosting_order_billing_cycle_to_string.php b/database/migrations/2026_04_02_121500_change_customer_hosting_order_billing_cycle_to_string.php new file mode 100644 index 0000000..6ee8958 --- /dev/null +++ b/database/migrations/2026_04_02_121500_change_customer_hosting_order_billing_cycle_to_string.php @@ -0,0 +1,27 @@ +string('billing_cycle', 20)->default('yearly')->change(); + }); + } + + public function down(): void + { + DB::table('customer_hosting_orders') + ->where('billing_cycle', 'semiannual') + ->update(['billing_cycle' => 'quarterly']); + + Schema::table('customer_hosting_orders', function (Blueprint $table) { + $table->enum('billing_cycle', ['monthly', 'quarterly', 'yearly', 'biennial'])->default('yearly')->change(); + }); + } +}; diff --git a/database/migrations/2026_04_04_100000_create_server_agents_table.php b/database/migrations/2026_04_04_100000_create_server_agents_table.php new file mode 100644 index 0000000..7e02f7d --- /dev/null +++ b/database/migrations/2026_04_04_100000_create_server_agents_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('customer_hosting_order_id')->constrained()->cascadeOnDelete(); + $table->uuid('uuid')->unique(); + $table->string('status', 40)->default('pending_registration'); + $table->string('hostname')->nullable(); + $table->string('agent_version', 120)->nullable(); + $table->string('platform', 120)->nullable(); + $table->json('capabilities')->nullable(); + $table->json('metadata')->nullable(); + $table->string('bootstrap_token_hash', 64)->nullable(); + $table->string('access_token_hash', 64)->nullable(); + $table->timestamp('access_token_issued_at')->nullable(); + $table->timestamp('registered_at')->nullable(); + $table->timestamp('last_heartbeat_at')->nullable(); + $table->string('last_seen_ip', 64)->nullable(); + $table->timestamps(); + + $table->unique('customer_hosting_order_id'); + $table->index(['status', 'last_heartbeat_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_agents'); + } +}; diff --git a/database/migrations/2026_04_04_100100_create_server_tasks_table.php b/database/migrations/2026_04_04_100100_create_server_tasks_table.php new file mode 100644 index 0000000..cdd37ce --- /dev/null +++ b/database/migrations/2026_04_04_100100_create_server_tasks_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('customer_hosting_order_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_agent_id')->nullable()->constrained()->nullOnDelete(); + $table->string('type', 80); + $table->string('status', 40)->default('queued'); + $table->json('payload'); + $table->json('result')->nullable(); + $table->unsignedTinyInteger('progress')->default(0); + $table->unsignedInteger('attempt_count')->default(0); + $table->timestamp('queued_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamp('last_heartbeat_at')->nullable(); + $table->text('error_message')->nullable(); + $table->timestamps(); + + $table->index(['customer_hosting_order_id', 'status']); + $table->index(['server_agent_id', 'status']); + $table->index(['type', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_tasks'); + } +}; diff --git a/database/migrations/2026_04_05_175627_add_guest_email_to_customer_hosting_orders_table.php b/database/migrations/2026_04_05_175627_add_guest_email_to_customer_hosting_orders_table.php new file mode 100644 index 0000000..84b9f2f --- /dev/null +++ b/database/migrations/2026_04_05_175627_add_guest_email_to_customer_hosting_orders_table.php @@ -0,0 +1,30 @@ +string('guest_email')->nullable()->after('user_id'); + $table->index('guest_email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('customer_hosting_orders', function (Blueprint $table) { + $table->dropIndex(['guest_email']); + $table->dropColumn('guest_email'); + }); + } +}; diff --git a/database/migrations/2026_04_05_180932_make_user_id_nullable_in_customer_hosting_orders_table.php b/database/migrations/2026_04_05_180932_make_user_id_nullable_in_customer_hosting_orders_table.php new file mode 100644 index 0000000..e4fdf69 --- /dev/null +++ b/database/migrations/2026_04_05_180932_make_user_id_nullable_in_customer_hosting_orders_table.php @@ -0,0 +1,25 @@ +foreignId('user_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('customer_hosting_orders', function (Blueprint $table) { + $table->foreignId('user_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_04_05_231447_sync_hosting_catalog_plan_updates.php b/database/migrations/2026_04_05_231447_sync_hosting_catalog_plan_updates.php new file mode 100644 index 0000000..d4a46ae --- /dev/null +++ b/database/migrations/2026_04_05_231447_sync_hosting_catalog_plan_updates.php @@ -0,0 +1,151 @@ +upsertStarterPlan(); + $this->reorderExistingSharedPlans(); + $this->updateProPlan(); + } + + public function down(): void + { + DB::table('hosting_products') + ->where('slug', 'starter-plan') + ->delete(); + + DB::table('hosting_products') + ->where('slug', 'basic-plan') + ->update([ + 'sort_order' => 1, + 'updated_at' => now(), + ]); + + DB::table('hosting_products') + ->where('slug', 'plus-plan') + ->update([ + 'sort_order' => 2, + 'updated_at' => now(), + ]); + + DB::table('hosting_products') + ->where('slug', 'growth-plan') + ->update([ + 'sort_order' => 3, + 'updated_at' => now(), + ]); + + DB::table('hosting_products') + ->where('slug', 'pro-plan') + ->update([ + 'description' => 'Unlimited websites with unmetered SSD storage and bandwidth, 25 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, unlimited FTP users, and unlimited MySQL databases.', + 'price_monthly' => 75.00, + 'price_yearly' => 900.00, + 'disk_gb' => null, + 'sort_order' => 4, + 'updated_at' => now(), + ]); + } + + private function upsertStarterPlan(): void + { + $existing = DB::table('hosting_products')->where('slug', 'starter-plan')->first(); + + $payload = [ + 'name' => 'Starter Plan', + 'description' => 'Entry-level single-domain hosting with 5 GB SSD storage, unmetered bandwidth, 1 free email account, free SSL + CDN, LiteSpeed servers, AI Website Builder, Softaculous installer, weekly backups, 2 FTP users, and 2 MySQL databases.', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 1.00, + 'price_quarterly' => null, + 'price_yearly' => 12.00, + 'price_biennial' => null, + 'setup_fee' => 0.00, + 'currency' => 'GHS', + 'disk_gb' => 5, + 'bandwidth_gb' => null, + 'cpu_cores' => null, + 'ram_mb' => null, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 1, + 'max_ftp_accounts' => 2, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, + 'features' => json_encode([ + 'LiteSpeed servers', + 'AI Website Builder', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Weekly backups', + ], JSON_THROW_ON_ERROR), + 'php_versions' => null, + 'contabo_product_id' => null, + 'contabo_region' => null, + 'is_active' => true, + 'is_featured' => false, + 'is_visible' => true, + 'sort_order' => 1, + 'deleted_at' => null, + 'updated_at' => now(), + ]; + + if ($existing) { + DB::table('hosting_products') + ->where('slug', 'starter-plan') + ->update($payload); + + return; + } + + DB::table('hosting_products')->insert($payload + [ + 'slug' => 'starter-plan', + 'created_at' => now(), + ]); + } + + private function reorderExistingSharedPlans(): void + { + DB::table('hosting_products') + ->where('slug', 'basic-plan') + ->update([ + 'sort_order' => 2, + 'updated_at' => now(), + ]); + + DB::table('hosting_products') + ->where('slug', 'plus-plan') + ->update([ + 'sort_order' => 3, + 'updated_at' => now(), + ]); + + DB::table('hosting_products') + ->where('slug', 'growth-plan') + ->update([ + 'sort_order' => 4, + 'updated_at' => now(), + ]); + } + + private function updateProPlan(): void + { + DB::table('hosting_products') + ->where('slug', 'pro-plan') + ->update([ + 'description' => 'Unlimited websites with 150 GB SSD storage and unmetered bandwidth, 25 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, unlimited FTP users, and unlimited MySQL databases.', + 'price_monthly' => 175.00, + 'price_yearly' => 2100.00, + 'disk_gb' => 150, + 'sort_order' => 5, + 'deleted_at' => null, + 'updated_at' => now(), + ]); + } +}; diff --git a/database/migrations/2026_04_05_235500_add_capacity_and_segment_fields_to_hosting_nodes_and_accounts_table.php b/database/migrations/2026_04_05_235500_add_capacity_and_segment_fields_to_hosting_nodes_and_accounts_table.php new file mode 100644 index 0000000..5873427 --- /dev/null +++ b/database/migrations/2026_04_05_235500_add_capacity_and_segment_fields_to_hosting_nodes_and_accounts_table.php @@ -0,0 +1,239 @@ +addColumnIfMissing('hosting_nodes', 'segment', function (Blueprint $table): void { + $table->string('segment')->default('general')->after('type'); + }); + $this->addColumnIfMissing('hosting_nodes', 'oversell_ratio', function (Blueprint $table): void { + $table->decimal('oversell_ratio', 5, 2)->default(3)->after('disk_gb'); + }); + $this->addColumnIfMissing('hosting_nodes', 'allocated_disk_gb', function (Blueprint $table): void { + $table->unsignedInteger('allocated_disk_gb')->default(0)->after('oversell_ratio'); + }); + $this->addColumnIfMissing('hosting_nodes', 'used_disk_gb', function (Blueprint $table): void { + $table->unsignedInteger('used_disk_gb')->default(0)->after('allocated_disk_gb'); + }); + $this->addColumnIfMissing('hosting_nodes', 'current_load_percent', function (Blueprint $table): void { + $table->decimal('current_load_percent', 5, 2)->default(0)->after('current_accounts'); + }); + $this->addIndexIfMissing('hosting_nodes', ['segment', 'status'], self::HOSTING_NODES_SEGMENT_STATUS_INDEX); + + $this->addColumnIfMissing('hosting_accounts', 'allocated_disk_gb', function (Blueprint $table): void { + $table->unsignedInteger('allocated_disk_gb')->default(0)->after('home_directory'); + }); + $this->addColumnIfMissing('hosting_accounts', 'inode_count', function (Blueprint $table): void { + $table->unsignedInteger('inode_count')->default(0)->after('disk_used_bytes'); + }); + $this->addColumnIfMissing('hosting_accounts', 'cpu_usage_percent', function (Blueprint $table): void { + $table->decimal('cpu_usage_percent', 8, 2)->nullable()->after('inode_count'); + }); + $this->addColumnIfMissing('hosting_accounts', 'last_usage_sync_at', function (Blueprint $table): void { + $table->timestamp('last_usage_sync_at')->nullable()->after('cpu_usage_percent'); + }); + + $this->backfillAccountAllocations(); + $this->backfillNodeCapacities(); + } + + public function down(): void + { + $this->dropColumnsIfPresent('hosting_accounts', [ + 'allocated_disk_gb', + 'inode_count', + 'cpu_usage_percent', + 'last_usage_sync_at', + ]); + + $this->dropIndexIfPresent('hosting_nodes', self::HOSTING_NODES_SEGMENT_STATUS_INDEX); + $this->dropColumnsIfPresent('hosting_nodes', [ + 'segment', + 'oversell_ratio', + 'allocated_disk_gb', + 'used_disk_gb', + 'current_load_percent', + ]); + } + + private function backfillAccountAllocations(): void + { + if (! Schema::hasTable('hosting_accounts') || ! Schema::hasColumn('hosting_accounts', 'allocated_disk_gb')) { + return; + } + + $productDiskById = Schema::hasTable('hosting_products') + ? DB::table('hosting_products')->pluck('disk_gb', 'id') + : collect(); + $planDiskById = Schema::hasTable('hosting_plans') + ? DB::table('hosting_plans')->pluck('disk_gb', 'id') + : collect(); + + $select = ['id', 'disk_used_bytes']; + + if (Schema::hasColumn('hosting_accounts', 'hosting_product_id')) { + $select[] = 'hosting_product_id'; + } + + if (Schema::hasColumn('hosting_accounts', 'hosting_plan_id')) { + $select[] = 'hosting_plan_id'; + } + + DB::table('hosting_accounts') + ->select($select) + ->orderBy('id') + ->chunkById(100, function ($accounts) use ($productDiskById, $planDiskById): void { + $hasLastUsageSyncAt = Schema::hasColumn('hosting_accounts', 'last_usage_sync_at'); + + foreach ($accounts as $account) { + $allocated = (int) ($productDiskById[$account->hosting_product_id] ?? 0); + + if ($allocated <= 0) { + $allocated = (int) ($planDiskById[$account->hosting_plan_id] ?? 0); + } + + $updates = [ + 'allocated_disk_gb' => $allocated, + ]; + + if ($hasLastUsageSyncAt) { + $updates['last_usage_sync_at'] = now(); + } + + DB::table('hosting_accounts') + ->where('id', $account->id) + ->update($updates); + } + }); + } + + private function backfillNodeCapacities(): void + { + if (! Schema::hasTable('hosting_nodes') || ! Schema::hasTable('hosting_accounts')) { + return; + } + + DB::table('hosting_nodes') + ->select(['id', 'disk_gb', 'max_accounts', 'status']) + ->orderBy('id') + ->chunkById(100, function ($nodes): void { + foreach ($nodes as $node) { + $aggregate = DB::table('hosting_accounts') + ->where('hosting_node_id', $node->id) + ->selectRaw('COUNT(*) as account_count') + ->selectRaw('COALESCE(SUM(allocated_disk_gb), 0) as allocated_disk_gb') + ->selectRaw('COALESCE(SUM(disk_used_bytes), 0) as used_disk_bytes') + ->first(); + + $currentAccounts = (int) ($aggregate->account_count ?? 0); + $allocatedDiskGb = (int) ($aggregate->allocated_disk_gb ?? 0); + $usedDiskGb = (int) ceil(((int) ($aggregate->used_disk_bytes ?? 0)) / self::BYTES_PER_GB); + $logicalCapacity = $node->disk_gb ? ((float) $node->disk_gb * 3) : 0; + $accountPercent = $node->max_accounts ? ($currentAccounts / $node->max_accounts) * 100 : 0; + $logicalPercent = $logicalCapacity > 0 ? ($allocatedDiskGb / $logicalCapacity) * 100 : 0; + $physicalPercent = $node->disk_gb ? ($usedDiskGb / $node->disk_gb) * 100 : 0; + $loadPercent = round(max($accountPercent, $logicalPercent, $physicalPercent), 2); + + DB::table('hosting_nodes') + ->where('id', $node->id) + ->update([ + 'allocated_disk_gb' => $allocatedDiskGb, + 'used_disk_gb' => $usedDiskGb, + 'current_accounts' => $currentAccounts, + 'current_load_percent' => $loadPercent, + 'status' => in_array($node->status, ['active', 'full'], true) + ? (($logicalCapacity > 0 && $allocatedDiskGb >= $logicalCapacity) || ($node->max_accounts && $currentAccounts >= $node->max_accounts) ? 'full' : 'active') + : $node->status, + ]); + } + }); + } + + private function addColumnIfMissing(string $table, string $column, callable $callback): void + { + if (! Schema::hasTable($table) || Schema::hasColumn($table, $column)) { + return; + } + + Schema::table($table, $callback); + } + + private function addIndexIfMissing(string $table, array $columns, string $indexName): void + { + if (! Schema::hasTable($table) || $this->hasIndex($table, $indexName)) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($columns, $indexName): void { + $table->index($columns, $indexName); + }); + } + + private function dropColumnsIfPresent(string $table, array $columns): void + { + if (! Schema::hasTable($table)) { + return; + } + + $presentColumns = array_values(array_filter($columns, fn (string $column): bool => Schema::hasColumn($table, $column))); + + if ($presentColumns === []) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($presentColumns): void { + $table->dropColumn($presentColumns); + }); + } + + private function dropIndexIfPresent(string $table, string $indexName): void + { + if (! Schema::hasTable($table) || ! $this->hasIndex($table, $indexName)) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($indexName): void { + $table->dropIndex($indexName); + }); + } + + private function hasIndex(string $table, string $indexName): bool + { + if (! Schema::hasTable($table)) { + return false; + } + + $driver = DB::getDriverName(); + + if ($driver === 'sqlite') { + $rows = DB::select(sprintf("PRAGMA index_list('%s')", str_replace("'", "''", $table))); + + foreach ($rows as $row) { + if (($row->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + + if ($driver === 'mysql') { + return DB::table('information_schema.statistics') + ->where('table_schema', DB::getDatabaseName()) + ->where('table_name', $table) + ->where('index_name', $indexName) + ->exists(); + } + + return false; + } +}; diff --git a/database/migrations/2026_04_06_001500_add_resource_policy_fields_to_hosting_accounts_and_create_alerts_table.php b/database/migrations/2026_04_06_001500_add_resource_policy_fields_to_hosting_accounts_and_create_alerts_table.php new file mode 100644 index 0000000..69392e1 --- /dev/null +++ b/database/migrations/2026_04_06_001500_add_resource_policy_fields_to_hosting_accounts_and_create_alerts_table.php @@ -0,0 +1,244 @@ +addColumnIfMissing('hosting_accounts', 'memory_used_mb', function (Blueprint $table): void { + $table->unsignedInteger('memory_used_mb')->default(0)->after('cpu_usage_percent'); + }); + $this->addColumnIfMissing('hosting_accounts', 'process_count', function (Blueprint $table): void { + $table->unsignedInteger('process_count')->default(0)->after('memory_used_mb'); + }); + $this->addColumnIfMissing('hosting_accounts', 'io_usage_mb', function (Blueprint $table): void { + $table->decimal('io_usage_mb', 8, 2)->nullable()->after('process_count'); + }); + $this->addColumnIfMissing('hosting_accounts', 'cpu_limit_percent', function (Blueprint $table): void { + $table->unsignedInteger('cpu_limit_percent')->nullable()->after('php_version'); + }); + $this->addColumnIfMissing('hosting_accounts', 'memory_limit_mb', function (Blueprint $table): void { + $table->unsignedInteger('memory_limit_mb')->nullable()->after('cpu_limit_percent'); + }); + $this->addColumnIfMissing('hosting_accounts', 'process_limit', function (Blueprint $table): void { + $table->unsignedInteger('process_limit')->nullable()->after('memory_limit_mb'); + }); + $this->addColumnIfMissing('hosting_accounts', 'io_limit_mb', function (Blueprint $table): void { + $table->unsignedInteger('io_limit_mb')->nullable()->after('process_limit'); + }); + $this->addColumnIfMissing('hosting_accounts', 'inode_limit', function (Blueprint $table): void { + $table->unsignedInteger('inode_limit')->nullable()->after('io_limit_mb'); + }); + $this->addColumnIfMissing('hosting_accounts', 'resource_status', function (Blueprint $table): void { + $table->string('resource_status')->default('active')->after('inode_limit'); + }); + $this->addColumnIfMissing('hosting_accounts', 'uploads_restricted', function (Blueprint $table): void { + $table->boolean('uploads_restricted')->default(false)->after('resource_status'); + }); + $this->addColumnIfMissing('hosting_accounts', 'is_flagged', function (Blueprint $table): void { + $table->boolean('is_flagged')->default(false)->after('uploads_restricted'); + }); + $this->addColumnIfMissing('hosting_accounts', 'warning_count', function (Blueprint $table): void { + $table->unsignedInteger('warning_count')->default(0)->after('is_flagged'); + }); + $this->addColumnIfMissing('hosting_accounts', 'cpu_breach_streak', function (Blueprint $table): void { + $table->unsignedInteger('cpu_breach_streak')->default(0)->after('warning_count'); + }); + $this->addColumnIfMissing('hosting_accounts', 'memory_breach_streak', function (Blueprint $table): void { + $table->unsignedInteger('memory_breach_streak')->default(0)->after('cpu_breach_streak'); + }); + $this->addColumnIfMissing('hosting_accounts', 'process_breach_streak', function (Blueprint $table): void { + $table->unsignedInteger('process_breach_streak')->default(0)->after('memory_breach_streak'); + }); + $this->addColumnIfMissing('hosting_accounts', 'io_breach_streak', function (Blueprint $table): void { + $table->unsignedInteger('io_breach_streak')->default(0)->after('process_breach_streak'); + }); + $this->addColumnIfMissing('hosting_accounts', 'inode_breach_streak', function (Blueprint $table): void { + $table->unsignedInteger('inode_breach_streak')->default(0)->after('io_breach_streak'); + }); + $this->addColumnIfMissing('hosting_accounts', 'last_warning_at', function (Blueprint $table): void { + $table->timestamp('last_warning_at')->nullable()->after('inode_breach_streak'); + }); + $this->addColumnIfMissing('hosting_accounts', 'last_resource_breach_at', function (Blueprint $table): void { + $table->timestamp('last_resource_breach_at')->nullable()->after('last_warning_at'); + }); + $this->addColumnIfMissing('hosting_accounts', 'throttled_at', function (Blueprint $table): void { + $table->timestamp('throttled_at')->nullable()->after('last_resource_breach_at'); + }); + $this->addIndexIfMissing('hosting_accounts', ['resource_status', 'status'], self::HOSTING_ACCOUNTS_RESOURCE_STATUS_INDEX); + $this->addIndexIfMissing('hosting_accounts', ['is_flagged', 'warning_count'], self::HOSTING_ACCOUNTS_FLAGGED_WARNING_INDEX); + + if (! Schema::hasTable('hosting_account_alerts')) { + Schema::create('hosting_account_alerts', function (Blueprint $table) { + $table->id(); + $table->foreignId('hosting_account_id')->constrained()->cascadeOnDelete(); + $table->string('alert_type'); + $table->string('severity')->default('warning'); + $table->text('message'); + $table->json('resource_usage')->nullable(); + $table->boolean('is_resolved')->default(false); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + + $table->index(['hosting_account_id', 'is_resolved']); + $table->index(['alert_type', 'is_resolved']); + }); + } + + $this->backfillResourcePolicyDefaults(); + } + + public function down(): void + { + Schema::dropIfExists('hosting_account_alerts'); + + $this->dropIndexIfPresent('hosting_accounts', self::HOSTING_ACCOUNTS_RESOURCE_STATUS_INDEX); + $this->dropIndexIfPresent('hosting_accounts', self::HOSTING_ACCOUNTS_FLAGGED_WARNING_INDEX); + $this->dropColumnsIfPresent('hosting_accounts', [ + 'memory_used_mb', + 'process_count', + 'io_usage_mb', + 'cpu_limit_percent', + 'memory_limit_mb', + 'process_limit', + 'io_limit_mb', + 'inode_limit', + 'resource_status', + 'uploads_restricted', + 'is_flagged', + 'warning_count', + 'cpu_breach_streak', + 'memory_breach_streak', + 'process_breach_streak', + 'io_breach_streak', + 'inode_breach_streak', + 'last_warning_at', + 'last_resource_breach_at', + 'throttled_at', + ]); + } + + private function backfillResourcePolicyDefaults(): void + { + if (! Schema::hasTable('hosting_accounts')) { + return; + } + + $products = Schema::hasTable('hosting_products') + ? DB::table('hosting_products')->get()->keyBy('id') + : collect(); + + DB::table('hosting_accounts') + ->select(['id', 'hosting_product_id', 'type', 'status']) + ->orderBy('id') + ->chunkById(100, function ($accounts) use ($products): void { + foreach ($accounts as $account) { + $product = $products->get($account->hosting_product_id); + $type = $product->type ?? null; + + $defaults = match ($type) { + 'wordpress' => [75, 768, 15, 8, 100000], + 'multi_domain' => [100, 1024, 20, 10, 150000], + default => [50, 512, 10, 5, 50000], + }; + + DB::table('hosting_accounts') + ->where('id', $account->id) + ->update([ + 'cpu_limit_percent' => $defaults[0], + 'memory_limit_mb' => $defaults[1], + 'process_limit' => $defaults[2], + 'io_limit_mb' => $defaults[3], + 'inode_limit' => $defaults[4], + 'resource_status' => $account->status === 'suspended' ? 'suspended' : 'active', + ]); + } + }); + } + + private function addColumnIfMissing(string $table, string $column, callable $callback): void + { + if (! Schema::hasTable($table) || Schema::hasColumn($table, $column)) { + return; + } + + Schema::table($table, $callback); + } + + private function addIndexIfMissing(string $table, array $columns, string $indexName): void + { + if (! Schema::hasTable($table) || $this->hasIndex($table, $indexName)) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($columns, $indexName): void { + $table->index($columns, $indexName); + }); + } + + private function dropColumnsIfPresent(string $table, array $columns): void + { + if (! Schema::hasTable($table)) { + return; + } + + $presentColumns = array_values(array_filter($columns, fn (string $column): bool => Schema::hasColumn($table, $column))); + + if ($presentColumns === []) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($presentColumns): void { + $table->dropColumn($presentColumns); + }); + } + + private function dropIndexIfPresent(string $table, string $indexName): void + { + if (! Schema::hasTable($table) || ! $this->hasIndex($table, $indexName)) { + return; + } + + Schema::table($table, function (Blueprint $table) use ($indexName): void { + $table->dropIndex($indexName); + }); + } + + private function hasIndex(string $table, string $indexName): bool + { + if (! Schema::hasTable($table)) { + return false; + } + + $driver = DB::getDriverName(); + + if ($driver === 'sqlite') { + $rows = DB::select(sprintf("PRAGMA index_list('%s')", str_replace("'", "''", $table))); + + foreach ($rows as $row) { + if (($row->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + + if ($driver === 'mysql') { + return DB::table('information_schema.statistics') + ->where('table_schema', DB::getDatabaseName()) + ->where('table_name', $table) + ->where('index_name', $indexName) + ->exists(); + } + + return false; + } +}; diff --git a/database/migrations/2026_04_06_120000_create_hosting_plan_changes_and_billing_invoices_table.php b/database/migrations/2026_04_06_120000_create_hosting_plan_changes_and_billing_invoices_table.php new file mode 100644 index 0000000..96f455f --- /dev/null +++ b/database/migrations/2026_04_06_120000_create_hosting_plan_changes_and_billing_invoices_table.php @@ -0,0 +1,85 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosting_account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_hosting_product_id')->constrained('hosting_products')->restrictOnDelete(); + $table->foreignId('to_hosting_product_id')->constrained('hosting_products')->restrictOnDelete(); + $table->string('direction'); + $table->string('billing_cycle')->default('monthly'); + $table->unsignedSmallInteger('cycle_months')->default(1); + $table->unsignedSmallInteger('remaining_days')->default(0); + $table->decimal('proration_ratio', 8, 4)->default(0); + $table->decimal('old_cycle_price', 10, 2)->default(0); + $table->decimal('new_cycle_price', 10, 2)->default(0); + $table->decimal('charge_amount', 10, 2)->default(0); + $table->decimal('credit_amount', 10, 2)->default(0); + $table->decimal('net_amount', 10, 2)->default(0); + $table->string('currency', 3)->default('GHS'); + $table->string('status')->default('applied'); + $table->timestamp('applied_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['hosting_account_id', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['direction', 'status']); + }); + } + + if (! Schema::hasTable('hosting_billing_invoices')) { + Schema::create('hosting_billing_invoices', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosting_account_id')->constrained()->cascadeOnDelete(); + $table->foreignId('hosting_plan_change_id')->nullable()->constrained()->nullOnDelete(); + $table->string('invoice_type'); + $table->string('status')->default('pending'); + $table->string('currency', 3)->default('GHS'); + $table->decimal('subtotal_amount', 10, 2)->default(0); + $table->decimal('credit_amount', 10, 2)->default(0); + $table->decimal('total_amount', 10, 2)->default(0); + $table->string('description')->nullable(); + $table->string('provider_reference')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->json('line_items')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['hosting_account_id', 'invoice_type']); + $table->index(['user_id', 'status']); + $table->index(['invoice_type', 'created_at']); + }); + } + + if (Schema::hasTable('mailboxes') && ! Schema::hasColumn('mailboxes', 'hosting_account_id')) { + Schema::table('mailboxes', function (Blueprint $table) { + $table->foreignId('hosting_account_id')->nullable()->after('website_id')->constrained()->nullOnDelete(); + $table->index(['hosting_account_id', 'status']); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('mailboxes') && Schema::hasColumn('mailboxes', 'hosting_account_id')) { + Schema::table('mailboxes', function (Blueprint $table) { + $table->dropIndex(['hosting_account_id', 'status']); + $table->dropConstrainedForeignId('hosting_account_id'); + }); + } + + Schema::dropIfExists('hosting_billing_invoices'); + Schema::dropIfExists('hosting_plan_changes'); + } +}; diff --git a/database/migrations/2026_04_06_150000_create_hosting_account_members_table.php b/database/migrations/2026_04_06_150000_create_hosting_account_members_table.php new file mode 100644 index 0000000..23ed34a --- /dev/null +++ b/database/migrations/2026_04_06_150000_create_hosting_account_members_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('hosting_account_id')->constrained('hosting_accounts')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('invited_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('role')->default('developer'); + $table->timestamp('invited_at')->nullable(); + $table->timestamps(); + + $table->unique(['hosting_account_id', 'user_id']); + $table->index(['user_id', 'role']); + }); + } + + public function down(): void + { + Schema::dropIfExists('hosting_account_members'); + } +}; diff --git a/database/migrations/2026_04_06_160000_add_ssh_key_columns_to_hosting_account_members_table.php b/database/migrations/2026_04_06_160000_add_ssh_key_columns_to_hosting_account_members_table.php new file mode 100644 index 0000000..c57a2ac --- /dev/null +++ b/database/migrations/2026_04_06_160000_add_ssh_key_columns_to_hosting_account_members_table.php @@ -0,0 +1,23 @@ +text('ssh_public_key')->nullable()->after('invited_at'); + $table->timestamp('ssh_key_installed_at')->nullable()->after('ssh_public_key'); + }); + } + + public function down(): void + { + Schema::table('hosting_account_members', function (Blueprint $table) { + $table->dropColumn(['ssh_public_key', 'ssh_key_installed_at']); + }); + } +}; diff --git a/database/migrations/2026_04_09_111734_add_ssl_status_to_hosted_sites_table.php b/database/migrations/2026_04_09_111734_add_ssl_status_to_hosted_sites_table.php new file mode 100644 index 0000000..047d586 --- /dev/null +++ b/database/migrations/2026_04_09_111734_add_ssl_status_to_hosted_sites_table.php @@ -0,0 +1,30 @@ +string('ssl_status')->nullable()->after('ssl_enabled'); + $table->text('ssl_error')->nullable()->after('ssl_status'); + $table->timestamp('ssl_provisioned_at')->nullable()->after('ssl_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('hosted_sites', function (Blueprint $table) { + $table->dropColumn(['ssl_status', 'ssl_error', 'ssl_provisioned_at']); + }); + } +}; diff --git a/database/migrations/2026_04_10_120000_increase_shared_hosting_inode_limits_by_200000.php b/database/migrations/2026_04_10_120000_increase_shared_hosting_inode_limits_by_200000.php new file mode 100644 index 0000000..186b86c --- /dev/null +++ b/database/migrations/2026_04_10_120000_increase_shared_hosting_inode_limits_by_200000.php @@ -0,0 +1,82 @@ + 150000, + 'basic-plan' => 300000, + 'plus-plan' => 300000, + 'growth-plan' => 500000, + 'pro-plan' => 800000, + 'wp-starter' => 300000, + 'wp-business' => 300000, + ]; + + foreach ($targets as $slug => $inodeLimit) { + $productId = (int) (DB::table('hosting_products')->where('slug', $slug)->value('id') ?? 0); + + if ($productId > 0) { + $this->setAccountsForProduct($productId, $inodeLimit); + } + } + } + + public function down(): void + { + if (! Schema::hasTable('hosting_products') || ! Schema::hasTable('hosting_accounts')) { + return; + } + + $fallbacks = [ + 'starter-plan' => 50000, + 'basic-plan' => 100000, + 'plus-plan' => 100000, + 'growth-plan' => 300000, + 'pro-plan' => 600000, + 'wp-starter' => 100000, + 'wp-business' => 100000, + ]; + + foreach ($fallbacks as $slug => $inodeLimit) { + $productId = (int) (DB::table('hosting_products')->where('slug', $slug)->value('id') ?? 0); + + if ($productId > 0) { + $this->setAccountsForProduct($productId, $inodeLimit); + } + } + } + + private function setAccountsForProduct(int $productId, int $inodeLimit): void + { + DB::table('hosting_accounts') + ->where('hosting_product_id', $productId) + ->orderBy('id') + ->chunkById(100, function ($accounts) use ($inodeLimit): void { + foreach ($accounts as $account) { + $resourceLimits = json_decode((string) ($account->resource_limits ?? '[]'), true); + if (! is_array($resourceLimits)) { + $resourceLimits = []; + } + + $resourceLimits['inode_limit'] = $inodeLimit; + + DB::table('hosting_accounts') + ->where('id', $account->id) + ->update([ + 'inode_limit' => $inodeLimit, + 'resource_limits' => json_encode($resourceLimits, JSON_UNESCAPED_UNICODE), + ]); + } + }); + } +}; diff --git a/database/migrations/2026_04_24_220000_increase_shared_hosting_resources_and_relax_resource_suspension.php b/database/migrations/2026_04_24_220000_increase_shared_hosting_resources_and_relax_resource_suspension.php new file mode 100644 index 0000000..35df7de --- /dev/null +++ b/database/migrations/2026_04_24_220000_increase_shared_hosting_resources_and_relax_resource_suspension.php @@ -0,0 +1,169 @@ + [ + 'description' => 'Entry-level single-domain hosting with 8 GB SSD storage, unmetered bandwidth, 2 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder, Softaculous installer, weekly backups, 3 FTP users, and 3 MySQL databases.', + 'disk_gb' => 8, + 'max_domains' => 1, + 'max_databases' => 3, + 'max_email_accounts' => 2, + 'max_ftp_accounts' => 3, + 'limits' => [75, 768, 15, 8, 225000], + ], + 'basic-plan' => [ + 'description' => 'Perfect for individuals & small business websites. Includes 15 GB SSD storage, unmetered bandwidth, 8 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder, Softaculous installer, weekly backups, 15 FTP users, and 15 MySQL databases.', + 'disk_gb' => 15, + 'max_domains' => 1, + 'max_databases' => 15, + 'max_email_accounts' => 8, + 'max_ftp_accounts' => 15, + 'limits' => [75, 768, 15, 8, 450000], + ], + 'plus-plan' => [ + 'description' => 'Enhanced single-domain hosting with 30 GB SSD storage, unmetered bandwidth, 15 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder + WordPress AI, Softaculous installer, twice-weekly backups, SSH access, 30 FTP users, and 30 MySQL databases.', + 'disk_gb' => 30, + 'max_domains' => 1, + 'max_databases' => 30, + 'max_email_accounts' => 15, + 'max_ftp_accounts' => 30, + 'limits' => [75, 768, 15, 8, 450000], + ], + 'growth-plan' => [ + 'description' => 'For businesses managing multiple websites. Host up to 8 websites with 45 GB SSD storage, unmetered bandwidth, 23 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, 75 FTP users, and 75 MySQL databases.', + 'disk_gb' => 45, + 'max_domains' => 8, + 'max_databases' => 75, + 'max_email_accounts' => 23, + 'max_ftp_accounts' => 75, + 'limits' => [150, 1536, 30, 15, 750000], + ], + 'pro-plan' => [ + 'description' => 'Unlimited websites with 225 GB SSD storage and unmetered bandwidth, 38 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, unlimited FTP users, and unlimited MySQL databases.', + 'disk_gb' => 225, + 'max_domains' => -1, + 'max_databases' => -1, + 'max_email_accounts' => 38, + 'max_ftp_accounts' => -1, + 'limits' => [150, 1536, 30, 15, 1200000], + ], + 'wp-starter' => [ + 'description' => 'Optimized WordPress hosting with 23 GB SSD storage, unmetered bandwidth, 8 free email accounts, free SSL + CDN, LiteSpeed + WordPress optimization, AI for WordPress, 1-click WordPress install, automatic updates, daily backups, and SSH access.', + 'disk_gb' => 23, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 8, + 'max_ftp_accounts' => null, + 'limits' => [113, 1152, 23, 12, 450000], + ], + 'wp-business' => [ + 'description' => 'Premium WordPress hosting with unlimited WordPress sites, 75 GB SSD storage, unmetered bandwidth, 38 free email accounts, advanced security (Imunify360-level), free SSL + CDN, LiteSpeed + advanced caching, AI for WordPress, staging environment, daily backups + restore points, SSH access, and priority performance.', + 'disk_gb' => 75, + 'max_domains' => -1, + 'max_databases' => -1, + 'max_email_accounts' => 38, + 'max_ftp_accounts' => null, + 'limits' => [113, 1152, 23, 12, 450000], + ], + ]; + + public function up(): void + { + if (! Schema::hasTable('hosting_products')) { + return; + } + + foreach ($this->plans as $slug => $plan) { + $product = DB::table('hosting_products')->where('slug', $slug)->first(); + + if (! $product) { + continue; + } + + DB::table('hosting_products') + ->where('id', $product->id) + ->update(array_filter([ + 'description' => $plan['description'], + 'disk_gb' => $plan['disk_gb'], + 'max_domains' => $plan['max_domains'], + 'max_databases' => $plan['max_databases'], + 'max_email_accounts' => $plan['max_email_accounts'], + 'max_ftp_accounts' => $plan['max_ftp_accounts'], + 'updated_at' => now(), + ], fn ($value) => $value !== null)); + + $this->updateAccountsForProduct((int) $product->id, $plan); + } + } + + public function down(): void + { + // Do not reduce customer quotas automatically on rollback. + } + + private function updateAccountsForProduct(int $productId, array $plan): void + { + if (! Schema::hasTable('hosting_accounts')) { + return; + } + + [$cpuLimit, $memoryLimit, $processLimit, $ioLimit, $inodeLimit] = $plan['limits']; + + DB::table('hosting_accounts') + ->where('hosting_product_id', $productId) + ->orderBy('id') + ->chunkById(100, function ($accounts) use ($plan, $cpuLimit, $memoryLimit, $processLimit, $ioLimit, $inodeLimit): void { + foreach ($accounts as $account) { + $resourceLimits = json_decode((string) ($account->resource_limits ?? '[]'), true); + if (! is_array($resourceLimits)) { + $resourceLimits = []; + } + + $resourceLimits['disk_gb'] = max((int) ($resourceLimits['disk_gb'] ?? 0), (int) $plan['disk_gb']); + $resourceLimits['cpu_limit_percent'] = max((int) ($resourceLimits['cpu_limit_percent'] ?? 0), $cpuLimit); + $resourceLimits['memory_limit_mb'] = max((int) ($resourceLimits['memory_limit_mb'] ?? 0), $memoryLimit); + $resourceLimits['process_limit'] = max((int) ($resourceLimits['process_limit'] ?? 0), $processLimit); + $resourceLimits['io_limit_mb'] = max((int) ($resourceLimits['io_limit_mb'] ?? 0), $ioLimit); + $resourceLimits['inode_limit'] = max((int) ($resourceLimits['inode_limit'] ?? 0), $inodeLimit); + $this->raiseFiniteResourceLimit($resourceLimits, 'max_domains', $plan['max_domains'] ?? null); + $this->raiseFiniteResourceLimit($resourceLimits, 'max_databases', $plan['max_databases'] ?? null); + $this->raiseFiniteResourceLimit($resourceLimits, 'max_email_accounts', $plan['max_email_accounts'] ?? null); + $this->raiseFiniteResourceLimit($resourceLimits, 'max_ftp_accounts', $plan['max_ftp_accounts'] ?? null); + + $updates = [ + 'allocated_disk_gb' => max((int) ($account->allocated_disk_gb ?? 0), (int) $plan['disk_gb']), + 'cpu_limit_percent' => max((int) ($account->cpu_limit_percent ?? 0), $cpuLimit), + 'memory_limit_mb' => max((int) ($account->memory_limit_mb ?? 0), $memoryLimit), + 'process_limit' => max((int) ($account->process_limit ?? 0), $processLimit), + 'io_limit_mb' => max((int) ($account->io_limit_mb ?? 0), $ioLimit), + 'inode_limit' => max((int) ($account->inode_limit ?? 0), $inodeLimit), + 'resource_limits' => json_encode($resourceLimits, JSON_UNESCAPED_UNICODE), + 'updated_at' => now(), + ]; + + DB::table('hosting_accounts') + ->where('id', $account->id) + ->update($updates); + } + }); + } + + private function raiseFiniteResourceLimit(array &$resourceLimits, string $key, mixed $newValue): void + { + if ($newValue === null) { + return; + } + + if ((int) $newValue === -1 || (int) ($resourceLimits[$key] ?? 0) === -1) { + $resourceLimits[$key] = -1; + return; + } + + $resourceLimits[$key] = max((int) ($resourceLimits[$key] ?? 0), (int) $newValue); + } +}; diff --git a/database/migrations/2026_05_17_120000_sync_contabo_server_product_pricing.php b/database/migrations/2026_05_17_120000_sync_contabo_server_product_pricing.php new file mode 100644 index 0000000..db4e70b --- /dev/null +++ b/database/migrations/2026_05_17_120000_sync_contabo_server_product_pricing.php @@ -0,0 +1,110 @@ +serverProducts() as $slug => $payload) { + DB::table('hosting_products') + ->where('slug', $slug) + ->update($payload + [ + 'currency' => 'GHS', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'price_biennial' => 0, + 'deleted_at' => null, + 'updated_at' => now(), + ]); + } + + $this->clearPricingCaches(); + } + + public function down(): void + { + if (! Schema::hasTable('hosting_products')) { + return; + } + + $legacy = [ + 'vps-s' => ['contabo_product_id' => 'V10', 'price_monthly' => 75, 'price_quarterly' => 215, 'price_yearly' => 720], + 'vps-m' => ['contabo_product_id' => 'V20', 'price_monthly' => 90, 'price_quarterly' => 255, 'price_yearly' => 865], + 'vps-l' => ['contabo_product_id' => 'V30', 'price_monthly' => 120, 'price_quarterly' => 340, 'price_yearly' => 1150], + 'vps-xl' => ['contabo_product_id' => 'V40', 'price_monthly' => 150, 'price_quarterly' => 430, 'price_yearly' => 1440], + ]; + + foreach ($legacy as $slug => $payload) { + DB::table('hosting_products') + ->where('slug', $slug) + ->update($payload + [ + 'updated_at' => now(), + ]); + } + + $this->clearPricingCaches(); + } + + /** + * @return array> + */ + private function serverProducts(): array + { + return [ + 'vps-s' => [ + 'description' => 'Entry-level VPS with 3 vCPU cores, 8 GB RAM, 75 GB NVMe SSD, and unlimited bandwidth. Perfect for small projects and development.', + 'disk_gb' => 75, + 'cpu_cores' => 3, + 'ram_mb' => 8192, + 'contabo_product_id' => 'V91', + 'contabo_region' => 'EU', + 'sort_order' => 10, + ], + 'vps-m' => [ + 'description' => 'Mid-range VPS with 6 vCPU cores, 12 GB RAM, 100 GB NVMe SSD, and unlimited bandwidth. Ideal for growing applications and medium traffic sites.', + 'disk_gb' => 100, + 'cpu_cores' => 6, + 'ram_mb' => 12288, + 'contabo_product_id' => 'V94', + 'contabo_region' => 'EU', + 'sort_order' => 11, + ], + 'vps-l' => [ + 'description' => 'High-performance VPS with 8 vCPU cores, 24 GB RAM, 200 GB NVMe SSD, and unlimited bandwidth. Built for demanding workloads and high-traffic applications.', + 'disk_gb' => 200, + 'cpu_cores' => 8, + 'ram_mb' => 24576, + 'contabo_product_id' => 'V97', + 'contabo_region' => 'EU', + 'sort_order' => 12, + ], + 'vps-xl' => [ + 'description' => 'Enterprise VPS with 12 vCPU cores, 48 GB RAM, 250 GB NVMe SSD, and unlimited bandwidth. Maximum performance for resource-intensive applications.', + 'disk_gb' => 250, + 'cpu_cores' => 12, + 'ram_mb' => 49152, + 'contabo_product_id' => 'V100', + 'contabo_region' => 'EU', + 'sort_order' => 13, + ], + ]; + } + + private function clearPricingCaches(): void + { + Cache::forget('contabo_products'); + + foreach (['V10', 'V20', 'V30', 'V40', 'V91', 'V94', 'V97', 'V100'] as $productId) { + Cache::forget("contabo_product_price_{$productId}"); + } + } +}; diff --git a/database/migrations/2026_05_30_100002_add_pool_fields_to_hosting_nodes.php b/database/migrations/2026_05_30_100002_add_pool_fields_to_hosting_nodes.php new file mode 100644 index 0000000..3a6c46d --- /dev/null +++ b/database/migrations/2026_05_30_100002_add_pool_fields_to_hosting_nodes.php @@ -0,0 +1,38 @@ +string('role', 20)->default('primary')->after('type'); + $table->foreignId('primary_hosting_node_id') + ->nullable() + ->after('role') + ->constrained('hosting_nodes') + ->nullOnDelete(); + + $table->index(['role', 'primary_hosting_node_id']); + }); + + DB::table('hosting_nodes')->update(['role' => 'primary']); + } + + public function down(): void + { + Schema::table('hosting_nodes', function (Blueprint $table) { + $table->dropForeign(['primary_hosting_node_id']); + $table->dropIndex(['role', 'primary_hosting_node_id']); + $table->dropColumn(['role', 'primary_hosting_node_id']); + }); + } +}; diff --git a/database/migrations/2026_05_30_120000_create_contabo_infrastructure_provisions_table.php b/database/migrations/2026_05_30_120000_create_contabo_infrastructure_provisions_table.php new file mode 100644 index 0000000..57d16a2 --- /dev/null +++ b/database/migrations/2026_05_30_120000_create_contabo_infrastructure_provisions_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('purpose'); + $table->string('status')->default('pending'); + $table->string('name'); + $table->string('contabo_product_id'); + $table->string('region'); + $table->string('image_id')->nullable(); + $table->string('contabo_instance_id')->nullable(); + $table->string('completion_token', 64); + $table->json('payload')->nullable(); + $table->json('log')->nullable(); + $table->text('ssh_public_key')->nullable(); + $table->text('ssh_private_key')->nullable(); + $table->text('generated_mail_db_password')->nullable(); + $table->foreignId('hosting_node_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedBigInteger('email_server_id')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('error_message')->nullable(); + $table->timestamp('instance_created_at')->nullable(); + $table->timestamp('ip_assigned_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'purpose']); + $table->index('completion_token'); + }); + } + + public function down(): void + { + Schema::dropIfExists('contabo_infrastructure_provisions'); + } +}; diff --git a/database/migrations/2026_05_30_200004_update_hosting_node_disk_to_150gb.php b/database/migrations/2026_05_30_200004_update_hosting_node_disk_to_150gb.php new file mode 100644 index 0000000..183f6e8 --- /dev/null +++ b/database/migrations/2026_05_30_200004_update_hosting_node_disk_to_150gb.php @@ -0,0 +1,23 @@ +where('provider', 'local') + ->where('disk_gb', 100) + ->update(['disk_gb' => 150]); + } + + public function down(): void + { + DB::table('hosting_nodes') + ->where('provider', 'local') + ->where('disk_gb', 150) + ->update(['disk_gb' => 100]); + } +}; diff --git a/database/migrations/2026_06_05_000001_create_email_team_members_table.php b/database/migrations/2026_06_05_000001_create_email_team_members_table.php new file mode 100644 index 0000000..68a52be --- /dev/null +++ b/database/migrations/2026_06_05_000001_create_email_team_members_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('account_id')->index(); // owner user id + $table->unsignedBigInteger('user_id')->nullable()->index(); // member user (set on accept) + $table->string('email'); + $table->string('role', 20)->default('member'); // admin|member + $table->string('status', 20)->default('invited'); // invited|active + $table->string('token', 64)->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['account_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_team_members'); + } +}; diff --git a/database/migrations/2026_06_05_000002_create_email_settings_table.php b/database/migrations/2026_06_05_000002_create_email_settings_table.php new file mode 100644 index 0000000..0d4198f --- /dev/null +++ b/database/migrations/2026_06_05_000002_create_email_settings_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->unsignedInteger('default_quota_mb')->nullable(); // default new-mailbox quota + $table->string('notify_email')->nullable(); // where billing/alerts go + $table->boolean('product_updates')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_settings'); + } +}; diff --git a/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php b/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_06_05_075448_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_06_06_000001_create_hosting_settings_table.php b/database/migrations/2026_06_06_000001_create_hosting_settings_table.php new file mode 100644 index 0000000..271a7b6 --- /dev/null +++ b/database/migrations/2026_06_06_000001_create_hosting_settings_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('notify_email')->nullable(); + $table->boolean('product_updates')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('hosting_settings'); + } +}; diff --git a/database/migrations/2026_06_06_000001_create_hosting_team_members_table.php b/database/migrations/2026_06_06_000001_create_hosting_team_members_table.php new file mode 100644 index 0000000..3fa00b6 --- /dev/null +++ b/database/migrations/2026_06_06_000001_create_hosting_team_members_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('account_id')->index(); // owner user id + $table->unsignedBigInteger('user_id')->nullable()->index(); // member user (set on accept) + $table->string('email'); + $table->string('role', 20)->default('member'); // admin|member + $table->string('status', 20)->default('invited'); // invited|active + $table->string('token', 64)->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->unique(['account_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('hosting_team_members'); + } +}; diff --git a/database/migrations/2026_06_06_120000_add_platform_id_to_hosting_import_tables.php b/database/migrations/2026_06_06_120000_add_platform_id_to_hosting_import_tables.php new file mode 100644 index 0000000..e9377ce --- /dev/null +++ b/database/migrations/2026_06_06_120000_add_platform_id_to_hosting_import_tables.php @@ -0,0 +1,42 @@ +unsignedBigInteger('platform_id')->nullable()->unique()->after('id'); + }); + } + } + } + + public function down(): void + { + foreach ([ + 'hosting_accounts', + 'customer_hosting_orders', + 'hosting_orders', + 'hosting_billing_invoices', + 'hosting_plan_changes', + ] as $table) { + if (Schema::hasTable($table) && Schema::hasColumn($table, 'platform_id')) { + Schema::table($table, function (Blueprint $table): void { + $table->dropColumn('platform_id'); + }); + } + } + } +}; diff --git a/database/migrations/2026_06_06_200000_create_notifications_table.php b/database/migrations/2026_06_06_200000_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/database/migrations/2026_06_06_200000_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..a81da2d --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,29 @@ +call([ + HostingProductSeeder::class, + ]); + // User::factory(10)->create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/database/seeders/HostingNodeSeeder.php b/database/seeders/HostingNodeSeeder.php new file mode 100644 index 0000000..4b70748 --- /dev/null +++ b/database/seeders/HostingNodeSeeder.php @@ -0,0 +1,40 @@ + 'local', 'ip_address' => '127.0.0.1'], + [ + 'name' => 'Primary Server', + 'hostname' => gethostname() ?: 'localhost', + 'ip_address' => '127.0.0.1', + 'ipv6_address' => '::1', + 'type' => 'shared', + 'segment' => 'general', + 'provider' => 'local', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 150, + 'oversell_ratio' => 3, + 'bandwidth_tb' => 10, + 'max_accounts' => 100, + 'current_accounts' => 0, + 'status' => 'active', + 'ssh_port' => 22, + 'features' => ['php', 'mysql', 'nginx', 'ssl'], + 'installed_software' => [ + 'php' => ['8.1', '8.2', '8.3'], + 'mysql' => '8.0', + 'nginx' => '1.24', + ], + ] + ); + } +} diff --git a/database/seeders/HostingProductSeeder.php b/database/seeders/HostingProductSeeder.php new file mode 100644 index 0000000..f5b65fd --- /dev/null +++ b/database/seeders/HostingProductSeeder.php @@ -0,0 +1,280 @@ + 'Starter Plan', + 'slug' => 'starter-plan', + 'description' => 'Entry-level single-domain hosting with 8 GB SSD storage, unmetered bandwidth, 2 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder, Softaculous installer, weekly backups, 3 FTP users, and 3 MySQL databases.', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 1.00, + 'price_yearly' => 12.00, // 1 × 12 + 'currency' => 'GHS', + 'disk_gb' => 8, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => 1, + 'max_databases' => 3, + 'max_email_accounts' => 2, + 'max_ftp_accounts' => 3, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, // Weekly backups + 'features' => [ + 'LiteSpeed servers', + 'AI Website Builder', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Weekly backups', + ], + 'is_active' => true, + 'is_featured' => false, + 'sort_order' => 1, + ], + [ + 'name' => 'Basic Plan', + 'slug' => 'basic-plan', + 'description' => 'Perfect for individuals & small business websites. Includes 15 GB SSD storage, unmetered bandwidth, 8 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder, Softaculous installer, weekly backups, 15 FTP users, and 15 MySQL databases.', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 25.00, + 'price_yearly' => 300.00, // 25 × 12 + 'currency' => 'GHS', + 'disk_gb' => 15, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => 1, + 'max_databases' => 15, + 'max_email_accounts' => 8, + 'max_ftp_accounts' => 15, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, // Weekly backups + 'features' => [ + 'LiteSpeed servers', + 'AI Website Builder', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Weekly backups', + ], + 'is_active' => true, + 'is_featured' => false, + 'sort_order' => 2, + ], + [ + 'name' => 'Plus Plan', + 'slug' => 'plus-plan', + 'description' => 'Enhanced single-domain hosting with 30 GB SSD storage, unmetered bandwidth, 15 free email accounts, free SSL + CDN, LiteSpeed servers, AI Website Builder + WordPress AI, Softaculous installer, twice-weekly backups, SSH access, 30 FTP users, and 30 MySQL databases.', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 35.00, + 'price_yearly' => 420.00, // 35 × 12 + 'currency' => 'GHS', + 'disk_gb' => 30, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => 1, + 'max_databases' => 30, + 'max_email_accounts' => 15, + 'max_ftp_accounts' => 30, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 4, // Twice-weekly backups + 'features' => [ + 'LiteSpeed servers', + 'AI Website Builder', + 'WordPress AI', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Twice-weekly backups', + 'SSH access', + ], + 'is_active' => true, + 'is_featured' => true, + 'sort_order' => 3, + ], + + // ============================================ + // MULTI-DOMAIN HOSTING + // For businesses managing multiple websites + // ============================================ + [ + 'name' => 'Growth Plan', + 'slug' => 'growth-plan', + 'description' => 'For businesses managing multiple websites. Host up to 8 websites with 45 GB SSD storage, unmetered bandwidth, 23 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, 75 FTP users, and 75 MySQL databases.', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 55.00, + 'price_yearly' => 660.00, // 55 × 12 + 'currency' => 'GHS', + 'disk_gb' => 45, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => 8, + 'max_databases' => 75, + 'max_email_accounts' => 23, + 'max_ftp_accounts' => 75, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, // Daily backups + 'features' => [ + 'LiteSpeed servers', + 'AI tools', + 'Site builders', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Daily backups', + 'SSH access', + ], + 'is_active' => true, + 'is_featured' => true, + 'sort_order' => 4, + ], + [ + 'name' => 'Pro Plan', + 'slug' => 'pro-plan', + 'description' => 'Unlimited websites with 225 GB SSD storage and unmetered bandwidth, 38 free email accounts, free SSL + CDN, LiteSpeed servers, AI tools + Site builders, Softaculous installer, daily backups, SSH access, unlimited FTP users, and unlimited MySQL databases.', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 175.00, + 'price_yearly' => 2100.00, // 175 × 12 + 'currency' => 'GHS', + 'disk_gb' => 225, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => -1, // Unlimited + 'max_databases' => -1, // Unlimited + 'max_email_accounts' => 38, + 'max_ftp_accounts' => -1, // Unlimited + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, // Daily backups + 'features' => [ + 'LiteSpeed servers', + 'AI tools', + 'Site builders', + 'Softaculous installer', + 'Free SSL', + 'Free CDN', + 'Daily backups', + 'SSH access', + 'Unlimited websites', + 'Unlimited FTP users', + 'Unlimited MySQL databases', + ], + 'is_active' => true, + 'is_featured' => false, + 'sort_order' => 5, + ], + + // ============================================ + // WORDPRESS HOSTING + // Optimized for WordPress performance & automation + // ============================================ + [ + 'name' => 'WP Starter', + 'slug' => 'wp-starter', + 'description' => 'Optimized WordPress hosting with 23 GB SSD storage, unmetered bandwidth, 8 free email accounts, free SSL + CDN, LiteSpeed + WordPress optimization, AI for WordPress, 1-click WordPress install, automatic updates, daily backups, and SSH access.', + 'category' => 'shared', + 'type' => 'wordpress', + 'price_monthly' => 40.00, + 'price_yearly' => 480.00, // 40 × 12 + 'currency' => 'GHS', + 'disk_gb' => 23, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 8, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 7, // Daily backups + 'features' => [ + 'LiteSpeed servers', + 'WordPress optimization', + 'AI for WordPress', + '1-click WordPress install', + 'Automatic updates', + 'Free SSL', + 'Free CDN', + 'Daily backups', + 'SSH access', + ], + 'is_active' => true, + 'is_featured' => true, + 'sort_order' => 5, + ], + [ + 'name' => 'WP Business', + 'slug' => 'wp-business', + 'description' => 'Premium WordPress hosting with unlimited WordPress sites, 75 GB SSD storage, unmetered bandwidth, 38 free email accounts, advanced security (Imunify360-level), free SSL + CDN, LiteSpeed + advanced caching, AI for WordPress, staging environment, daily backups + restore points, SSH access, and priority performance.', + 'category' => 'shared', + 'type' => 'wordpress', + 'price_monthly' => 85.00, + 'price_yearly' => 1020.00, // 85 × 12 + 'currency' => 'GHS', + 'disk_gb' => 75, + 'bandwidth_gb' => null, // Unmetered + 'max_domains' => -1, // Unlimited WordPress sites + 'max_databases' => -1, // Unlimited + 'max_email_accounts' => 38, + 'ssl_included' => true, + 'backups_included' => true, + 'backup_retention_days' => 30, // Daily backups + restore points + 'features' => [ + 'LiteSpeed servers', + 'Advanced caching', + 'WordPress optimization', + 'AI for WordPress', + 'Staging environment', + 'Advanced security (Imunify360-level)', + 'Free SSL', + 'Free CDN', + 'Daily backups', + 'Restore points', + 'SSH access', + 'Priority performance', + 'Unlimited WordPress sites', + ], + 'is_active' => true, + 'is_featured' => false, + 'sort_order' => 6, + ], + + // VPS & dedicated → Ladill Servers (servers.ladill.com). See ServerProductSeeder there. + ]; + + foreach ($products as $product) { + HostingProduct::updateOrCreate( + ['slug' => $product['slug']], + $product + ); + } + + // Clean up old hosting products that are no longer in the new pricing structure + $newSlugs = array_column($products, 'slug'); + $oldSlugsToRemove = [ + 'starter-hosting', + 'basic-hosting', + 'business-hosting', + 'pro-hosting', + 'wordpress-starter', + 'wordpress-business', + ]; + + HostingProduct::whereIn('slug', $oldSlugsToRemove) + ->whereNotIn('slug', $newSlugs) + ->delete(); + } +} diff --git a/deploy/bin/ladill-hosting-admin b/deploy/bin/ladill-hosting-admin new file mode 100755 index 0000000..88b5ff1 --- /dev/null +++ b/deploy/bin/ladill-hosting-admin @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' >&2 +Usage: + ladill-hosting-admin self-check + ladill-hosting-admin mkdir + ladill-hosting-admin chown + ladill-hosting-admin chmod + ladill-hosting-admin write-file + ladill-hosting-admin symlink + ladill-hosting-admin nginx-test + ladill-hosting-admin reload-nginx + ladill-hosting-admin certbot-webroot + ladill-hosting-admin certbot-renew + ladill-hosting-admin run-cmd + ladill-hosting-admin read-file +EOF + exit 64 +} + +require_abs_path() { + local path="${1:-}" + + if [[ -z "$path" || "${path#/}" == "$path" ]]; then + echo "Expected an absolute path, got: $path" >&2 + exit 64 + fi +} + +ensure_domain() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z0-9.-]+$ ]]; then + echo "Invalid domain: $value" >&2 + exit 64 + fi +} + +ensure_owner_group() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[A-Za-z_][A-Za-z0-9_-]*:[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid owner:group value: $value" >&2 + exit 64 + fi +} + +ensure_mode() { + local value="${1:-}" + + if [[ ! "$value" =~ ^[0-7]{3,4}$ && ! "$value" =~ ^[ugoa,+-=rwxX]+$ ]]; then + echo "Invalid chmod mode: $value" >&2 + exit 64 + fi +} + +command="${1:-}" + +case "$command" in + self-check) + exit 0 + ;; + mkdir) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec mkdir -p "$2" + ;; + chown) + [[ $# -eq 3 ]] || usage + ensure_owner_group "$2" + require_abs_path "$3" + exec chown -R "$2" "$3" + ;; + chmod) + [[ $# -eq 3 ]] || usage + ensure_mode "$2" + require_abs_path "$3" + exec chmod "$2" "$3" + ;; + write-file) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + tmpfile="$(mktemp)" + trap 'rm -f "$tmpfile"' EXIT + printf '%s' "$3" | base64 --decode > "$tmpfile" + install -m 0644 "$tmpfile" "$2" + rm -f "$tmpfile" + trap - EXIT + ;; + symlink) + [[ $# -eq 3 ]] || usage + require_abs_path "$2" + require_abs_path "$3" + exec ln -sfn "$2" "$3" + ;; + nginx-test) + exec nginx -t + ;; + reload-nginx) + exec systemctl reload nginx + ;; + certbot-webroot) + [[ $# -eq 4 ]] || usage + ensure_domain "$3" + require_abs_path "$4" + exec certbot certonly --webroot --non-interactive --agree-tos --no-eff-email -m "$2" -w "$4" -d "$3" -d "www.$3" + ;; + certbot-renew) + exec certbot renew --quiet --no-random-sleep-on-renew + ;; + run-cmd) + [[ $# -ge 2 ]] || usage + shift + exec bash -c "$*" + ;; + read-file) + [[ $# -eq 2 ]] || usage + require_abs_path "$2" + exec cat "$2" + ;; + *) + usage + ;; +esac diff --git a/deploy/bin/ladill-hosting-user b/deploy/bin/ladill-hosting-user new file mode 100755 index 0000000..bc3a45a --- /dev/null +++ b/deploy/bin/ladill-hosting-user @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: ladill-hosting-user " >&2 + exit 64 +} + +[[ $# -eq 2 ]] || usage + +username="$1" +payload_b64="$2" + +if [[ ! "$username" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + echo "Invalid username: $username" >&2 + exit 64 +fi + +command="$(printf '%s' "$payload_b64" | base64 --decode)" + +exec runuser -u "$username" -- bash -lc "$command" diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..5951829 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh). +set -Eeuo pipefail + +APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-servers}" +RELEASES_DIR="$APP_ROOT/releases" +SHARED_DIR="$APP_ROOT/shared" +CURRENT_LINK="$APP_ROOT/current" +RELEASE_ARCHIVE="${LADILL_RELEASE_ARCHIVE:-/tmp/ladill-release.tgz}" +KEEP_RELEASES="${LADILL_KEEP_RELEASES:-5}" + +STAMP="$(date +%Y%m%d%H%M%S)" +NEW_RELEASE="$RELEASES_DIR/$STAMP" + +log() { + printf '[%s] %s\n' "$(date '+%F %T')" "$*" +} + +bootstrap_shared_env() { + local candidate="" + + if [ -f "$SHARED_DIR/.env" ]; then + return 0 + fi + + for candidate in "$CURRENT_LINK/.env" "$APP_ROOT/.env"; do + if [ -f "$candidate" ]; then + cp -fL "$candidate" "$SHARED_DIR/.env" + log "Bootstrapped shared .env from $candidate" + return 0 + fi + done + + return 1 +} + +ensure_writable_paths() { + local paths=("$@") + + [ "${#paths[@]}" -gt 0 ] || return 0 + + chmod -R ug+rwX "${paths[@]}" 2>/dev/null || true + + if command -v find >/dev/null 2>&1; then + find "${paths[@]}" -type d -exec chmod ug+rwx {} + 2>/dev/null || true + find "${paths[@]}" -type d -exec chmod g+s {} + 2>/dev/null || true + fi +} + +run_artisan() { + local command="$1" + + if [ -f "$NEW_RELEASE/artisan" ]; then + (cd "$NEW_RELEASE" && php artisan ${command}) + fi +} + +uses_sqlite() { + [ -f "$SHARED_DIR/.env" ] && grep -Eq '^DB_CONNECTION=sqlite([[:space:]]*)$' "$SHARED_DIR/.env" +} + +sqlite_database_setting() { + [ -f "$SHARED_DIR/.env" ] || return 1 + + grep -E '^DB_DATABASE=' "$SHARED_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//" +} + +prepare_sqlite_database() { + local configured_path="" + local db_name="" + local shared_sqlite_path="" + local current_sqlite_path="" + local release_sqlite_path="" + + configured_path="$(sqlite_database_setting || true)" + [ -n "$configured_path" ] || configured_path="database/database.sqlite" + + if [[ "$configured_path" = /* ]]; then + shared_sqlite_path="$configured_path" + mkdir -p "$(dirname "$shared_sqlite_path")" + else + db_name="$(basename "$configured_path")" + shared_sqlite_path="$SHARED_DIR/database/$db_name" + current_sqlite_path="$CURRENT_LINK/$configured_path" + release_sqlite_path="$NEW_RELEASE/$configured_path" + + mkdir -p "$SHARED_DIR/database" "$(dirname "$release_sqlite_path")" + + if [ ! -f "$shared_sqlite_path" ]; then + if [ -f "$current_sqlite_path" ]; then + cp -fL "$current_sqlite_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $current_sqlite_path" + elif [ -f "$APP_ROOT/$configured_path" ]; then + cp -fL "$APP_ROOT/$configured_path" "$shared_sqlite_path" + log "Bootstrapped shared sqlite database from $APP_ROOT/$configured_path" + else + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + fi + + rm -f "$release_sqlite_path" + ln -sfn "$shared_sqlite_path" "$release_sqlite_path" + ensure_writable_paths "$SHARED_DIR/database" + return 0 + fi + + if [ ! -f "$shared_sqlite_path" ]; then + touch "$shared_sqlite_path" + log "Created shared sqlite database at $shared_sqlite_path" + fi + + ensure_writable_paths "$(dirname "$shared_sqlite_path")" +} + +log "Preparing release $STAMP" +[ -f "$RELEASE_ARCHIVE" ] || { echo "Release archive not found: $RELEASE_ARCHIVE" >&2; exit 1; } + +mkdir -p "$RELEASES_DIR" "$SHARED_DIR" "$NEW_RELEASE" +tar -xzf "$RELEASE_ARCHIVE" -C "$NEW_RELEASE" +chmod -R u+rwX "$NEW_RELEASE" 2>/dev/null || true + +log "Linking shared paths" +mkdir -p "$SHARED_DIR/storage/"{app/public,app/private,framework/{cache/data,sessions,testing,views},logs} +mkdir -p "$SHARED_DIR/bootstrap-cache" +ensure_writable_paths "$SHARED_DIR/storage" "$SHARED_DIR/bootstrap-cache" + +if bootstrap_shared_env; then + ln -sfn "$SHARED_DIR/.env" "$NEW_RELEASE/.env" +else + echo "Missing deployment environment file. Expected $SHARED_DIR/.env or an existing $CURRENT_LINK/.env / $APP_ROOT/.env to bootstrap from." >&2 + exit 1 +fi + +rm -rf "$NEW_RELEASE/storage" +ln -sfn "$SHARED_DIR/storage" "$NEW_RELEASE/storage" +mkdir -p "$NEW_RELEASE/bootstrap" +rm -rf "$NEW_RELEASE/bootstrap/cache" +ln -sfn "$SHARED_DIR/bootstrap-cache" "$NEW_RELEASE/bootstrap/cache" +ensure_writable_paths "$NEW_RELEASE/storage" "$NEW_RELEASE/bootstrap/cache" + +if uses_sqlite; then + log "Preparing shared sqlite database" + prepare_sqlite_database + if id -u www-data >/dev/null 2>&1; then + chgrp www-data "$SHARED_DIR/database" 2>/dev/null || true + find "$SHARED_DIR/database" -type f -exec chgrp www-data {} + 2>/dev/null || true + find "$SHARED_DIR/database" -type d -exec chgrp www-data {} + 2>/dev/null || true + fi +fi + +log "Installing PHP dependencies" +if [ -f "$NEW_RELEASE/composer.json" ]; then + if [ "$(id -u)" -eq 0 ]; then + export COMPOSER_ALLOW_SUPERUSER=1 + fi + export COMPOSER_HOME="${COMPOSER_HOME:-/home/deploy/.composer}" + ( + cd "$NEW_RELEASE" + composer install \ + --no-dev \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --optimize-autoloader \ + --classmap-authoritative + ) +fi + +log "Running migrations" +if ! run_artisan "migrate --force"; then + log "Migration failed — clearing shared bootstrap cache" + run_artisan "optimize:clear" || true + exit 1 +fi + +log "Switching current release" +ln -sfnT "$NEW_RELEASE" "$CURRENT_LINK" + +log "Optimizing Laravel" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + ( + cd "$CURRENT_LINK" + php artisan storage:link || true + php artisan optimize:clear || true + php artisan config:cache || true + php artisan route:cache || true + php artisan view:cache || true + ) +fi + +log "Reloading services" +if command -v systemctl >/dev/null 2>&1; then + systemctl reload php8.4-fpm 2>/dev/null || sudo -n systemctl reload php8.4-fpm + systemctl reload nginx 2>/dev/null || sudo -n systemctl reload nginx +fi + +log "Restarting queues" +if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then + (cd "$CURRENT_LINK" && php artisan queue:restart) || true +fi +if command -v supervisorctl >/dev/null 2>&1; then + supervisorctl restart ladill-servers-worker:* 2>/dev/null || true +fi + +log "Cleaning old releases" +mapfile -t OLD_RELEASES < <(ls -1dt "$RELEASES_DIR"/* 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" || true) +if [ "${#OLD_RELEASES[@]}" -gt 0 ]; then + chmod -R u+rwX "${OLD_RELEASES[@]}" 2>/dev/null || true + rm -rf "${OLD_RELEASES[@]}" 2>/dev/null || true +fi + +log "Deploy completed: $STAMP" diff --git a/deploy/sudoers.ladill-hosting.example b/deploy/sudoers.ladill-hosting.example new file mode 100644 index 0000000..4dc5ce2 --- /dev/null +++ b/deploy/sudoers.ladill-hosting.example @@ -0,0 +1,7 @@ +# Replace `www-data` with the actual PHP-FPM/web user that runs the app. +# Install as `/etc/sudoers.d/ladill-hosting` with mode `0440`. +# +# These helpers are root-owned fixed entry points installed by the deploy script. + +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-admin * +www-data ALL=(root) NOPASSWD: /usr/local/bin/ladill-hosting-user * diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..52a40bb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2532 @@ +{ + "name": "ladill-email", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + } + }, + "node_modules/@alpinejs/collapse": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@alpinejs/collapse/-/collapse-3.15.12.tgz", + "integrity": "sha512-BKNANLtNXuWYOSAnajSKLPjTsmHRNrv0ALFTbpmqt2/klHFooPhctSwkhFVPQb7rZ8BjEKHmNaBwnSbgtpk6xg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz", + "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.1.5" + } + }, + "node_modules/@vue/shared": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz", + "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alpinejs": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", + "integrity": "sha512-nJvPAQVNPdZZ0NrExJ/kzQco3ijR8LwvCOadQecllESiqT4NyZ/57sN9V2XyvhlBGAbmlKYgeWZvYdKq99ij/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.1.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.1.0.tgz", + "integrity": "sha512-z+ck2BSV6KWtYcoIzk9Y5+p4NEjqM+Y4i8/H+VZRLq0OgNjW2DqyADquwYu5j8qRvaXwzNmfCWl1KrMlV1zpsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^7.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/vite": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fbc81f2 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.11.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^2.0.0", + "tailwindcss": "^4.0.0", + "vite": "^7.0.7" + }, + "dependencies": { + "@alpinejs/collapse": "^3.15.12", + "@tailwindcss/forms": "^0.5.11", + "alpinejs": "^3.15.12" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fdd9742e290cce685cf8c51d915ace6e82774df2 GIT binary patch literal 8786 zcmch7WmHsO)c2iX=nesCkVZ-Y34uXCKtf>Xl8{C^rDgz?k`i?2lI|4Ap+hO@9uR2( z$)TV5zaQT9e0V><_pW>Ixo5Av_V4Vo*FJlneE0|02T?I>+66;dJw zB5adXO;t(vKk9!E0Uq{a=2>j_-=CYOmL6y{&o2!C*w56I6!iS(|1Je&Soj2-{~JA( z*<7~U$ol!3M*y7XQ1P(Bvf-X!1ym{BpyCBMbzNuSa#9FVF|D##S^zewXwV%bp_s(K z$DaW4?;$PFr!9C`%VSlWpkbH&ea0(f=D1(>lU?d-ZuTE-W(P9@!p|N(PW2kT2p&DX z$|Q{y?W4E(-!Gr%rK{<)hpVmtKIi$h*S#nXy60{;duCT^scDDpj4i5?JlzGn-#0NY zB>C<(rxBREFC!a6erAhE&|%vTi7>CEF-zL5XZl(c$b1oXr8Iq?X}Ro2;62@Xbb{+W z-{^rtZ`gz{+uI4Aq6HTFxER}+3M&)Z_s@}pKMjrGkLZwuuMzjJVyh@~$OqV6@V9Bl z=?-06H6)O)pdW!HI|sC9|I@aU*dl4RpZWjsBnQ4_Spi&z%d+ggSJUEy8!+p`>gEk&@5u zTp=`wA_QslEh_418d(@!w8u|eL&5sC=~jn>{XI^;q_gD?raw&Yh!Hb!%uTu~4zJ=6 zPY*LtvZy7FqcYq#gLD@3{B?G{(L3aDpD%B?`R{Q}Eha_Tgp!rg27=3GIWD{_2EDZtH8&EEBqa42pwqRl2;BTDI8v9vf?~bP_k%NLAmDFtE zTW-0vFUE=c3(NK;gJc5GGX&0vgDk1XSJ!ol0qwPWNjn5^(y|u99<}w~hI~i%D4D(u z#i<;~H3{a$qs(T-41uik?bOB(>4bv2Cf)P*s)I<2N{%ehOVEalkm&w%+k^#p*StP+ zzc4TzY|XQ(N=RxZ5x1re?*O#&UuX&J=i{H4f?x4>Jyx?EoQiRYe{wgm-$F2MnWnOm zJCu!MueaU4DBzwRZ5F=i0lBjdM<33oAvh@CnlLHP0EXbI;$LB)pWq8Phcj$VsOS57 z^N(+{S#Cx?!F2K7blR*LOl2WjDZ+rg!N2yWin%ybj2_KmBm~0Sdry6tQA@0I3Xd{< zzpt)7Dc#|L&**#9aMv2Y3#H|m$t6M5_Dth3{3>vkR(r2S^at~ih!uAkh!C$Qlz|K5 zalFE1R>G-#<+DA$3rvSP2-NgNEgl}q=%1SKm*wf&$e;J-1u;}*qpqa<8xqG7j&Fq4 zpk2>sgms!|F*gj1*MJvXzrT56hR&m1X-dxRe+QsSK&$+p<`L>6Tei}i>NDfO?a7yPTJxaAnr22NDu(_t$b~mE zNl39-Y(3LDf2n=J43jNu(>7ij6#{lb#qVs$Gu>$?g|;3OMxSsoINMqst3puk<6EH# z8t<{(@KJ5qB1~Ka&7lx~u0)aNTy2RVb7p2T2kZLKIF+)z!XN3Bx0GPIT(i~5Vc1D8 zK30cIon;pC_<|`b90UCj$Exld4#;QrAZ_rB_lHd8V)ZZO+pnvQ@+dC6?p1;}W>)Q_ z(bmfHdCwMV#WO#!0=tH8xq7@uhvbbJC8dUwj-7o4I&#Gxi|p;35@tK`=PzI!9?|@F zK!!>6ytY_gqntFsXvyjbx=8r6&L~m7+|^9J;GJXfaCa{P zI*;g3#QepZUFSyEN=`p19m>Ur{%H;&)iwvUen*_8C|jKVwm9KZ8anULt{;c(R)lUc zP$Ay?n}e?T`ZwR+{GHoTTw&6fQCokd04E`&|#fJ)Xcz+q?T8E$OlA6p- zDYGB>+eURQFf`G4-4pW4P2b?b8lQPPE;u#jh=n_}T&L{`*zL^Yy6}37S03YE^|6=% zc9x~Lr3Dy(Ki;5K|C|=zg7CK-m{I1~X-gUv2BzfkMjFi{w|VJ`er=xx3E58-*GzZN zfQA_1UKJemxepazZ`2YNg2>qTQ?QODP-rMyTk7N3A&u#_#;b z{bOeFkDlw?v<(Cr>`{YVyLVjk=#``v;%);qzzOBN2fk|=`uU9^p2Zo;e++zJiGfrkys+OD%Cr5xMLIn1b~o12kYn$e{~1?~H$QdG+k_T%l} z2%BUAO%tQB>TL)xnghUtLJvvwZtKR*+2*te+FHo*g=3MoH$cGcKD{^CVMv3zk{Mh zq_{c$mW9fS0$kM?d;zyu(|@&dKKsS|Ycu_Nia|P#^gUa4qFv#@~TtwyppzsdeXq-numvsB`z;M$7|v+RVQ5f z|D4fTY>|B6tMH61~sbN|9qVTl0-pVYW; zH;nMLr z0u$wjfJX319oo{N{JzMi#yV-eha{!ZxbPAjHE7!zPC!{7O<#xhGb&4v@24H^E-ri$0L|RQaO>GGE8W@`4;07*(l1;(_ z5L&ACZPShKn{Lo7cYZm=m}*%(ic{^?b9zK zGkdV0sUhnJvgPa=WI)?7Y+A9KD#*V(I+@2|y1+6j8ZC^2EUqYSDgCQK5>n(rqR@6> z*nkWc%pD9wiFfp_2qK5~)D-K{abEdx%#^AI{Vq+R1^O1NYr|7D?Wc)F ztzH(NQ3V~zDb5p)TBeU3%mJ^mcAV}I5si$<=MwFfjeE`FIAPc6sJ7_a`-fVVz9~WJ zqK7c_ih`Shc*j|2+j(S=6)|7~Z?K^oerQ^^A&TzKTg>a?Pd-MR{hk!u#73wM;3uO_ zYI4~Ey_ar3gk`Pou%i+U#OoYNdAMu#Nxg5C{D%TI*blDY{{f!?@pM{MHZs30yHk%i zfS31QZR#skT};@jT(i~!B6cejTeHE0Z}w&HmIGcsT5liN6~G|$9P!BB`5^#Tg3Q{ZZboOKw(fDKi^@?&|F#wVn@++$K^MlHu^P~LsV616qbQ%h^ zzYaP)tHR8ug>0U#dP40J5_}JQM2CXzZRi+FYQ*38jc#`h%;t>he5YFtP-=YO!a40Q z>Jw=*h=rjzd3u`?$TED@7ji>oX>3SmGZA)@H4z^1=~vpfk(gVj8{hVmEcH@5(}zH; zRMIZKfI%$2V#rFzZh6hFo?Bh5bl;@EG|5;aSG)uUDd3VaTy;oElaVOlnH zs!W{qdk`+}!`#m{3Dyr4q!RqS3{4dm$3;fV!%oSVvNw}99H{#b5~ zMu~fKzw|6^Bt})Y)cWJJag{tQ2UZPMayrEeUy!uP|9sX5TVY(|J-Y0rZR6<=1qOd`I6 z1O`^6MBa{SvYgyF42W`A`X|ZXT;mUF+H$c9A(#(LFQH0E)|Baf;-v2-N|)0~^*+QN zPd!&z4Gr-{k|P7Y1|xY)RCEx@ev0G8eiAsSRrh!3`Qd-&y+GMpmzMF=_W2P{Wa%E+aGgb&uA~C*UdQF zhJ77AW_&TkGYYA}wC6MVr0{dxE5AFd`3opn*%$UGK7K#hX=1-If+ra(`j@R3IBMpL*HZ zC~2ge?AHQAS~c_U=tk$PS*k2NItzDi8K9`E&?PHSYs<#IP-$o(rKj+-=`Hy9=J@CM z8?1CecmG;xWLB=s7kke0;am6R-Ho1?tV?`w@Mz-(W^^uGNsG{>(#^@}JrnUY_k06g z$++!~*mujqeaYaogR8f%n$YAItD{VQZjwhJHlmk}aYqw#I6XF*aM6bOGy4J26}Lit zeCH5nDw8~OzqVG{<;8pYAxm z+B{bZ;j-I4qc-(EBo3Ea-t!7R%xK2|6qe3jh*v)ultA`8Cz_h%$s9aev?lbG-ERaQ zcORG`Pj3uArCv1SlACM#pEq}t1E5P?Tb2w_jqwuZ3r$VpNXbsOTz&igQNin#EyyshCuu<4P*oT@7S@xpBX+ zn;71|@O;}kQP@v5bR zJYy9`*B0tzhpF2+t^AG`pPZSsG_s!p5dU_m(KzoxlTg@knoq?2dBq2fwb5gF-Sr|T zS5_IJ_N6B@ix=w%94Oq3{#egBZlLl|TzrJ`v0|XAuFEU~!HY6h2eK&XJj;J-6W2HR z+W<|L8^1_G^j=U$$%VOmen72H(2!ec5{=`p#Jc0xBdqJzCexwLcUbLXg;`a788s^C7^0~}6=O)AhkC6Q8o@%<{Q;qwKchmrwgp1=n zRiggTWUKq0Rn`c1-Ib3rd+&;dXpk1vDjv|@auu2l2!+x0H*+&{?)OOnc%^K4DQ!m2MNUP2zp?J2y(1GLRn=kR>OH32+L+Vg7x%m=dDQaD zc5muS;aR*VrKYr#${k@oWs(U;N$=wi4(a-AFhcmah@Dt$#p^<$jl289#_VKcIC}dZ z#u~$%EyQw;Q-kg4TsbLC-)NYtIwk_9KjkP413Ii`8P3}-zMZHj@H6zi8KtRsRHy}J zTM_wo&Eor!H#)L??ZVPSJDJ8RbckwV@&l(bF+98yM{hY?n_DzkyGWh-LDPW2okr%~ zatdRpOQ;+&;xk{O6J_pj6w};HIHOl9L5ec8hCs3yj!ZbJSH4Z1(53O6{n$|`=j-y( z-O2DylaE*26XdaVI1NHxO4ZI?hdRLu5_ruyAMFSDk0EgxKzbRM@jXBtF5;lL#4dfsxxM-<_^8X@|T zOPMdLvh7{$hWnhS zW4vi$P2VV_Q=~eBLTz@kI3NtzWpH2?_Q|}FNu+{i{#lMF>DcX)-9v1bfFaQI*;Cb& z579<4i@cP(RiXOzQ-qJGjmNSDO}b6!OyfjyW%D zt~t1)W8voQVWaLqtJzQ?sBbbAtoieFLDkV}4FFXAtyUePz4snMVoTD$hpMWk8aD@b zJ>fne18_IiVLyNHR5np$GtDUO|I`hXZd(;z?I@r;k0rRn_A+< zBEpb{i+lL;M~{29>;m0Y3Tsg$+nJnp9lW+)a!WWw#6?YZ8RyZ2_26fJUmQy zoI-TpntZsn%NZjsA`_o5Aq%Jvj#$>4tGp+&fgC;;5V)yZqeE7WewtiMHn@s60-%4K zIs?Ab%z2_<6;fG0Hux5Zaz#}VDJYZ1bv7>FXw0xM-71sbc9X$9<~#?=u4;b@ef9U8 z=WQ!_WLxChh(bP?-H*RZ+x;eD`iiaK-BRe8b=%gLaBe*#u{3L6_Ri@-j?wxJde!I2 z%5M_Kkln7Q%vtc*4TmX^-6iwa!By?J^?|qJAd!&jm%VYH-ar01$P>U0vv&4MWZvem zEozbP19u{EShxF?Ik7yo(gWUqxhlRB%2)D(Enf}mp!mA))5aCjYdm-PK{J=gCz=$= z2VoIrb3$0g_UB51t&>c;e*Y)Tft*h;;j6zM-i;0^{9h&8Hgg@ZnjLMDQuqtEQYANj zYe`Dc4G(i;W-{`Tf=l?7D{|y7#yoPXi z3gDHqe4-LiXt?wL`n1s|eefRYFXV%wf9H;9LC-_e`=!#^fpE&Q-sB!=NGlGnu`IVf z&8*8CsLzf`GfSj8bfi?2OQ*mf45!fuOQPL;OA!O-FGLv#bSIO*_ael|{HJ$BOW056 z%SsKRN>j8Giu&hGz1&BinZ4Ix8lpCcS1Uq}@;f;L`V~1SFYJEMPIB{oQxP8&x8=Z& zXZ1hf4&SvG);u_+mqm~x=NyLy$zpoUJta{Bt%zaNguxdaIt~5M{rskKiQ6(H&b0*E zqpVlq)Bwx|dB&trecyj*#}jHwnZ-r%IVc37pI<5i%#dGy!R`gIfVV-p&fC%%!YL~7 z4{!Fpq45N@z4Lb_TfiE}A)Afi_f#BQ=yB!07(##3DVh7n<%9t!_-3VG9^uKkRJ|Jcsc(23XZ@i=ssv=q2Z)b z7R*LseD^IKbQ9H}704Im?ll@`eXE(B;W|~!ck}>lXz-zJ54c!v58V>NESmL5fvq06 zUm3C5qC?)b%+FU)G;H#;z_oHL8)pBupgvv6flweb_o-*r1rNIfQI8D-tkKiP^L3hg zM=K2Jc#F8LENB*VNErc;4t0I+Ntt#|4*$0TIaKFoAKP*Uk#l&e#Q)srDx;g*hoxuX zx4&CrFGf{R$N_52pO+tb02!fE&p5d7D)Vbb31T^}iT_~K(w;Fs7oWpjG?&Vt3?jya zgn#*M<^C$XNit-|tqTC$5%~pef9_LW5PfWp=3f*VxxpvwPkK4rFs@g}UJc?sLj*+) zbw6X!AzXJ(2-<{X zrLR?O%OJtca6dUC6#jb&B4O5!izYenA$5NnPU8&0IBa1`4fBXAO&op~k(D={4g9L-RguQ>jeqsO=Se}TRL{p*}tDh$)u3ec+?;l}3uTmrM}!tYL}w;y^3`RFy{ z%sOCiH6NZJwS})m52#m~MAj3Hd|yCRiJSqQ(q}k4N5-tm57b=_XV^h$p`ar^OT$dJ zJ(0uFoNp+&ui#C*A~^sjt}=mNN(yh$;kJFg4Cdb#hL{WXuGxLjm^Ui{R5gy`{VmbANp5m;R?avV33obLiBsUtLL|X>R!i zu8L?^{><`gfdO%!*(q8?m7+VY!<|ymHJKCu6l%H^nem@XZ{D)3Zb`0((D+?^YGtA3@o7C7Pp~@p;J&<$x@4@A2=IyOSY!Sa{XZv6l>b?mKa)>N0%E! zHdX{m5lFQa5gy2_^8P-Qqz!0XI}r?IHRVDuFiY0ZrS^ zgGdPQU-_1B3*bShNXO0XcvzQ^-mYw#v2NGSm-G; z{qGm?rH#k_F4;v5r>@j1raxh2bPHLo+}n88Y#BJ05|+4!PH2BQ>Pn42_nB89eh)Ci zY;UW71jai8u?yABjEJ#rRQ|0FA-pXd3S*JFziy`AMj(lUB;$;GmM#zR ze&7Nj>vy2FFje)mB?i?xj2jW=Bm<5DY0)hAA>cpYC%VxxI!dtTU z>gMX9{uj;wu+AzW^8o=r8EOki5B|`wsjLZ{_n#T|M#qgRL~vk?$`XC V-mt7(>~Ro4O<7B+@|k7W{{W*8k@Wxo literal 0 HcmV?d00001 diff --git a/public/images/ladill-icons/bird.svg b/public/images/ladill-icons/bird.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/ladill-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/domains.svg b/public/images/ladill-icons/domains.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/ladill-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/pro.svg b/public/images/ladill-icons/pro.svg new file mode 100644 index 0000000..8ab5fc4 --- /dev/null +++ b/public/images/ladill-icons/pro.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/ladill-icons/qrplus.svg b/public/images/ladill-icons/qrplus.svg new file mode 100644 index 0000000..c055427 --- /dev/null +++ b/public/images/ladill-icons/qrplus.svg @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/public/images/ladill-icons/server.svg b/public/images/ladill-icons/server.svg new file mode 100644 index 0000000..5fbb2ac --- /dev/null +++ b/public/images/ladill-icons/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/ladill-icons/wordpress.svg b/public/images/ladill-icons/wordpress.svg new file mode 100644 index 0000000..0be90e4 --- /dev/null +++ b/public/images/ladill-icons/wordpress.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/launcher-icons/bird.svg b/public/images/launcher-icons/bird.svg new file mode 100644 index 0000000..ba4956e --- /dev/null +++ b/public/images/launcher-icons/bird.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/domains.svg b/public/images/launcher-icons/domains.svg new file mode 100644 index 0000000..ad8a1a1 --- /dev/null +++ b/public/images/launcher-icons/domains.svg @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/email.svg b/public/images/launcher-icons/email.svg new file mode 100644 index 0000000..72877c8 --- /dev/null +++ b/public/images/launcher-icons/email.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/hosting.svg b/public/images/launcher-icons/hosting.svg new file mode 100644 index 0000000..bd7b96f --- /dev/null +++ b/public/images/launcher-icons/hosting.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/mail.svg b/public/images/launcher-icons/mail.svg new file mode 100644 index 0000000..17ee6ca --- /dev/null +++ b/public/images/launcher-icons/mail.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/qrplus.svg b/public/images/launcher-icons/qrplus.svg new file mode 100644 index 0000000..023c58d --- /dev/null +++ b/public/images/launcher-icons/qrplus.svg @@ -0,0 +1,25 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/launcher-icons/servers.svg b/public/images/launcher-icons/servers.svg new file mode 100644 index 0000000..169dc36 --- /dev/null +++ b/public/images/launcher-icons/servers.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo-white.svg b/public/images/logo/ladilldomains-logo-white.svg new file mode 100644 index 0000000..0ba849c --- /dev/null +++ b/public/images/logo/ladilldomains-logo-white.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladilldomains-logo.svg b/public/images/logo/ladilldomains-logo.svg new file mode 100644 index 0000000..72e4745 --- /dev/null +++ b/public/images/logo/ladilldomains-logo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillemail-logo.svg b/public/images/logo/ladillemail-logo.svg new file mode 100644 index 0000000..f5d4f15 --- /dev/null +++ b/public/images/logo/ladillemail-logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillhosting-logo.svg b/public/images/logo/ladillhosting-logo.svg new file mode 100644 index 0000000..6a66d01 --- /dev/null +++ b/public/images/logo/ladillhosting-logo.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/logo/ladillservers-logo.svg b/public/images/logo/ladillservers-logo.svg new file mode 100644 index 0000000..01d019d --- /dev/null +++ b/public/images/logo/ladillservers-logo.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/app.css b/resources/css/app.css new file mode 100644 index 0000000..3f3c8d4 --- /dev/null +++ b/resources/css/app.css @@ -0,0 +1,16 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source '../**/*.blade.php'; +@source '../**/*.js'; + +@theme { + --font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} + +[x-cloak] { + display: none !important; +} diff --git a/resources/js/app.js b/resources/js/app.js new file mode 100644 index 0000000..c63c6c1 --- /dev/null +++ b/resources/js/app.js @@ -0,0 +1,184 @@ +import './bootstrap'; + +import Alpine from 'alpinejs'; +import collapse from '@alpinejs/collapse'; + +Alpine.plugin(collapse); + +Alpine.data('notificationDropdown', (config = {}) => ({ + open: false, + loading: false, + notifications: [], + unreadCount: 0, + unreadUrl: config.unreadUrl || '/notifications/unread', + markReadUrl: config.markReadUrl || '/notifications/__ID__/read', + markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read', + indexUrl: config.indexUrl || '/notifications', + csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '', + + init() { + this.fetchUnread(); + setInterval(() => this.fetchUnread(), 60000); + }, + + async fetchUnread() { + try { + const res = await fetch(this.unreadUrl, { + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.notifications = data.notifications || []; + this.unreadCount = data.unread_count || 0; + } catch (e) { + console.error('Failed to fetch notifications', e); + } + }, + + toggle() { + this.open = !this.open; + if (this.open) { + this.loading = true; + this.fetchUnread().finally(() => { this.loading = false; }); + } + }, + + async markRead(id) { + const url = this.markReadUrl.replace('__ID__', id); + try { + await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = this.notifications.filter(n => n.id !== id); + this.unreadCount = Math.max(0, this.unreadCount - 1); + } catch (e) { + console.error('Failed to mark notification as read', e); + } + }, + + async markAllRead() { + try { + await fetch(this.markAllReadUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': this.csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + }); + this.notifications = []; + this.unreadCount = 0; + } catch (e) { + console.error('Failed to mark all notifications as read', e); + } + }, + + getIconBg(icon) { + const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' }; + return map[icon] || 'bg-slate-100'; + }, + + getIconColor(icon) { + const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' }; + return map[icon] || 'text-slate-500'; + }, +})); + +// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button +// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade. +Alpine.data('afia', (config = {}) => ({ + open: false, + input: '', + loading: false, + messages: [ + { role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" }, + ], + suggestions: config.suggestions || [], + init() { + window.addEventListener('afia-open', () => { + this.open = true; + this.$nextTick(() => this.$refs.input && this.$refs.input.focus()); + }); + }, + close() { this.open = false; }, + useSuggestion(s) { this.input = s; this.send(); }, + scrollDown() { + this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; }); + }, + async send() { + const text = this.input.trim(); + if (!text || this.loading) return; + const history = this.messages.map((m) => ({ role: m.role, text: m.text })); + this.messages.push({ role: 'user', text }); + this.input = ''; + this.loading = true; + this.scrollDown(); + try { + const res = await fetch(config.chatUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' }, + body: JSON.stringify({ message: text, history }), + }); + const data = await res.json(); + this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' }); + } catch (e) { + this.messages.push({ role: 'assistant', text: 'Network error — please try again.' }); + } + this.loading = false; + this.scrollDown(); + }, +})); + +Alpine.data('topbarSearch', (config = {}) => ({ + query: '', + results: [], + open: false, + loading: false, + active: 0, + _abort: null, + searchUrl: config.searchUrl || '/search', + + onFocus() { + if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true; + }, + + async search() { + const q = this.query.trim(); + if (q.length < 2) { this.results = []; this.open = false; return; } + + this.loading = true; + this.open = true; + this.active = 0; + + if (this._abort) this._abort.abort(); + this._abort = new AbortController(); + + try { + const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, { + signal: this._abort.signal, + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + }); + const data = await res.json(); + this.results = data.results || []; + } catch (e) { + if (e.name !== 'AbortError') this.results = []; + } finally { + this.loading = false; + } + }, + + moveDown() { if (this.active < this.results.length - 1) this.active++; }, + moveUp() { if (this.active > 0) this.active--; }, + go() { + if (this.results[this.active]) window.location.href = this.results[this.active].url; + }, +})); + +window.Alpine = Alpine; +Alpine.start(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php new file mode 100644 index 0000000..96a7909 --- /dev/null +++ b/resources/views/components/app-layout.blade.php @@ -0,0 +1,33 @@ +@props(['title' => 'Ladill Hosting']) + + + + + + + {{ $title ?? 'Ladill Hosting' }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + {{ $slot }} +
+
+
+ @auth + @include('partials.afia') + @endauth + + diff --git a/resources/views/components/hosting-panel-layout.blade.php b/resources/views/components/hosting-panel-layout.blade.php new file mode 100644 index 0000000..978ac9e --- /dev/null +++ b/resources/views/components/hosting-panel-layout.blade.php @@ -0,0 +1,73 @@ + + + + + + + @include('partials.favicon') + {{ $title ?? 'Hosting Panel' }} - Ladill + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + +@php + $canViewAccountDetails = auth()->check() && auth()->user()->can('viewAccount', $account); + $exitUrl = $canViewAccountDetails + ? route('hosting.accounts.show', $account) + : route('hosting.single-domain'); +@endphp + +
+ {{-- Mobile sidebar overlay --}} +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+ {{-- Top bar --}} +
+ +
+

{{ $header ?? 'Hosting Panel' }}

+ @unless ($canViewAccountDetails) +

Developer access

+ @endunless +
+ + + Exit Panel + +
+ + {{-- Flash messages --}} + @include('partials.flash') + + {{-- Expired Account Banner --}} + @if ($account->isExpired() && $account->isInGracePeriod()) +
+
+ +

Account expired. Your site is offline. Files are preserved until {{ $account->gracePeriodEndsAt()->format('M d, Y') }}. + Renew now +

+
+
+ @endif + + {{-- Page content --}} +
+ {{ $slot }} +
+
+
+ + @stack('scripts') + + diff --git a/resources/views/components/icons/domain-globe.blade.php b/resources/views/components/icons/domain-globe.blade.php new file mode 100644 index 0000000..f1d97c5 --- /dev/null +++ b/resources/views/components/icons/domain-globe.blade.php @@ -0,0 +1,4 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp +{!! \App\Support\DomainGlobeIcon::svg($class) !!} diff --git a/resources/views/components/icons/multi-domain-hosting.blade.php b/resources/views/components/icons/multi-domain-hosting.blade.php new file mode 100644 index 0000000..43890e5 --- /dev/null +++ b/resources/views/components/icons/multi-domain-hosting.blade.php @@ -0,0 +1,6 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp + diff --git a/resources/views/components/icons/qr-code.blade.php b/resources/views/components/icons/qr-code.blade.php new file mode 100644 index 0000000..3cdcc63 --- /dev/null +++ b/resources/views/components/icons/qr-code.blade.php @@ -0,0 +1,5 @@ +@props(['class' => 'h-5 w-5']) + +merge(['class' => $class]) }} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> + + diff --git a/resources/views/components/icons/single-domain-hosting.blade.php b/resources/views/components/icons/single-domain-hosting.blade.php new file mode 100644 index 0000000..69304cd --- /dev/null +++ b/resources/views/components/icons/single-domain-hosting.blade.php @@ -0,0 +1,7 @@ +@php + $class = $class ?? 'h-5 w-5'; + $strokeWidth = $strokeWidth ?? '1.5'; +@endphp + diff --git a/resources/views/components/icons/unlimited.blade.php b/resources/views/components/icons/unlimited.blade.php new file mode 100644 index 0000000..e7553f4 --- /dev/null +++ b/resources/views/components/icons/unlimited.blade.php @@ -0,0 +1,6 @@ +@php + $class = $class ?? 'h-5 w-5'; +@endphp + diff --git a/resources/views/components/user-layout.blade.php b/resources/views/components/user-layout.blade.php new file mode 100644 index 0000000..96a7909 --- /dev/null +++ b/resources/views/components/user-layout.blade.php @@ -0,0 +1,33 @@ +@props(['title' => 'Ladill Hosting']) + + + + + + + {{ $title ?? 'Ladill Hosting' }} + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + {{ $slot }} +
+
+
+ @auth + @include('partials.afia') + @endauth + + diff --git a/resources/views/email/account/billing.blade.php b/resources/views/email/account/billing.blade.php new file mode 100644 index 0000000..2f7442a --- /dev/null +++ b/resources/views/email/account/billing.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.email') +@section('title', 'Billing — Ladill Email') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Billing

+

Your Ladill wallet funds mailboxes across every Ladill app.

+ +
+
+

Wallet balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+
+

Spent on email

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+ +
+
+

Mailbox subscriptions

+ priced by storage plan +
+ @if(empty($paidMailboxes)) +

No paid mailboxes yet — your free allowance covers you.

+ @else +
    + @foreach($paidMailboxes as $m) +
  • +
    +

    {{ $m['address'] ?? '—' }}

    +

    {{ $m['quota_label'] }} · {{ ucfirst($m['status'] ?? 'active') }}

    +
    + {{ $fmt($m['price_minor']) }}/mo +
  • + @endforeach +
+
+ Monthly total + {{ $fmt($monthlyTotalMinor) }} +
+ @endif +
+

Mailboxes beyond your free allowance renew monthly from your wallet. Keep it funded to avoid interruption.

+
+@endsection diff --git a/resources/views/email/account/developers.blade.php b/resources/views/email/account/developers.blade.php new file mode 100644 index 0000000..28d1b4a --- /dev/null +++ b/resources/views/email/account/developers.blade.php @@ -0,0 +1,70 @@ +@extends('layouts.email') +@section('title', 'Developers — Ladill Email') +@section('content') +
+

Developers

+

API tokens to manage your mailboxes programmatically.

+ + @if($newToken) +
+

Your new token — copy it now

+

This is the only time it will be shown.

+
+ {{ $newToken }} + +
+
+ @endif + + {{-- Create --}} +
+

Create a token

+
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ + {{-- Tokens --}} +
+

Your tokens

+ @forelse($tokens as $token) +
+
+

{{ $token->name }}

+

+ Created {{ $token->created_at->diffForHumans() }} · + {{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }} +

+
+
+ @csrf @method('DELETE') + +
+
+ @empty +

No tokens yet.

+ @endforelse +
+ + {{-- Docs --}} +
+

Quick start

+

Authenticate with a Bearer token. Base URL:

+ {{ $apiBase }} +
curl {{ $apiBase }}/mailboxes \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Accept: application/json"
+

Endpoints: GET /me, GET /mailboxes. More coming soon.

+
+
+@endsection diff --git a/resources/views/email/account/settings.blade.php b/resources/views/email/account/settings.blade.php new file mode 100644 index 0000000..7ec7bfc --- /dev/null +++ b/resources/views/email/account/settings.blade.php @@ -0,0 +1,101 @@ +@extends('layouts.email') +@section('title', 'Settings — Ladill Email') +@section('content') +
+

Settings

+

Defaults, preferences, and account mailbox linking.

+ + @if($showMailboxLinkUi ?? (($linkStatus['linked_mailbox'] ?? null) || ($linkStatus['show_reminder'] ?? false))) +
+
+ + @include('partials.ladill-pro-icon') + +
+

Link to mailbox

+

+ Your Ladill account uses {{ $linkStatus['account_email'] ?? $account->email }}. + Link a mailbox so Ladill Mail opens with your Ladill sign-in. +

+ + @if($linkStatus['linked_mailbox'] ?? null) +
+ Linked mailbox: {{ $linkStatus['linked_mailbox'] }} +
+
+ @csrf @method('DELETE') + +
+ @elseif(($linkStatus['stage'] ?? '') === 'needs_domain') +

Add and verify an email domain first.

+ + Go to domains + + + @elseif(($linkStatus['stage'] ?? '') === 'needs_mailbox') +

Create a mailbox on your verified domain first.

+ + Create mailbox + + + @elseif(count($mailboxOptions) > 0) +
+ @csrf @method('PUT') +
+ + + @error('mailbox_address')

{{ $message }}

@enderror +
+ +
+ @endif +
+
+
+ @endif + +
+ @csrf @method('PUT') + +
+

Mailbox defaults

+
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+
+@endsection diff --git a/resources/views/email/account/team.blade.php b/resources/views/email/account/team.blade.php new file mode 100644 index 0000000..48793ae --- /dev/null +++ b/resources/views/email/account/team.blade.php @@ -0,0 +1,82 @@ +@extends('layouts.email') +@section('title', 'Team — Ladill Email') +@section('content') +
+

Team

+

Invite people to help manage this account’s mailboxes & domains.

+ + @if($canManage) +
+

Invite a teammate

+
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + +
+ +
+

Admins can manage mailboxes and the team. Members can manage mailboxes. Invitees join by signing in with that email.

+
+ @endif + +
+

Members

+
    +
  • +
    + {{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }} +
    +

    {{ $account->name ?? $account->email }} (you)

    +

    {{ $account->email }}

    +
    +
    + Owner +
  • + + @forelse($members as $member) +
  • +
    + {{ strtoupper(substr($member->email, 0, 1)) }} +
    +

    {{ $member->member->name ?? $member->email }}

    +

    {{ $member->email }}

    +
    +
    +
    + @if($member->status === 'invited') + Invited + @endif + @if($canManage) +
    + @csrf @method('PATCH') + +
    +
    + @csrf @method('DELETE') + +
    + @else + {{ $member->role }} + @endif +
    +
  • + @empty +
  • No teammates yet.
  • + @endforelse +
+
+
+@endsection diff --git a/resources/views/email/account/wallet.blade.php b/resources/views/email/account/wallet.blade.php new file mode 100644 index 0000000..59a772b --- /dev/null +++ b/resources/views/email/account/wallet.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.email') +@section('title', 'Wallet — Ladill Email') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Wallet

+

Your Ladill wallet funds mailboxes across every Ladill app.

+ +
+

Balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+ +
+
+

Spent on email

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+

Mailboxes beyond your free allowance are billed monthly from this wallet.

+
+@endsection diff --git a/resources/views/email/dashboard.blade.php b/resources/views/email/dashboard.blade.php new file mode 100644 index 0000000..d48d16b --- /dev/null +++ b/resources/views/email/dashboard.blade.php @@ -0,0 +1,47 @@ +@extends('layouts.email') +@section('title', 'Overview — Ladill Email') +@section('content') +
+
+
+

Overview

+

Your mailboxes at a glance.

+
+ New mailbox +
+ + @if($error)
{{ $error }}
@endif + +
+ @foreach([['Mailboxes', $mailboxCount, route('email.mailboxes.index')], ['Domains', $domainCount, route('email.domains.index')], ['Verified domains', $verifiedDomainCount, route('email.domains.index')]] as [$label, $value, $link]) + +

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ +
+
+

Recent mailboxes

+ View all +
+ @if(empty($recent)) +
+

No mailboxes yet

+

Add a domain, verify it, then create your first mailbox.

+ Set up a domain +
+ @else +
+ @foreach($recent as $m) + + {{ $m['address'] }} + {{ $m['status'] }} + + @endforeach +
+ @endif +
+
+@endsection diff --git a/resources/views/email/domains/index.blade.php b/resources/views/email/domains/index.blade.php new file mode 100644 index 0000000..6ae7e6c --- /dev/null +++ b/resources/views/email/domains/index.blade.php @@ -0,0 +1,30 @@ +@extends('layouts.email') +@section('title', 'Domains — Ladill Email') +@section('content') +
+

Email domains

+

Add a domain and verify it to create mailboxes on it.

+ @if($error)
{{ $error }}
@endif + +
+ @csrf + + +
+ + @if(!empty($domains)) + + @endif +
+@endsection diff --git a/resources/views/email/domains/show.blade.php b/resources/views/email/domains/show.blade.php new file mode 100644 index 0000000..7805fb8 --- /dev/null +++ b/resources/views/email/domains/show.blade.php @@ -0,0 +1,62 @@ +@extends('layouts.email') +@section('title', $domain['domain'].' — Ladill Email') +@section('content') +
+
+ Domains/ + {{ $domain['domain'] }} +
+
+

{{ $domain['domain'] }}

+ @if($domain['active'] ?? false) + Verified + @else + Pending + @endif +
+ + @unless($domain['active'] ?? false) +
+

Publish these DNS records

+ @php $records = $domain['dns_records'] ?? []; @endphp + @if(empty($records)) +

No DNS records returned. Try refreshing.

+ @else + + + + + + @foreach($records as $r) + + + + + + @endforeach + +
TypeNameValue
{{ $r['type'] ?? '' }}{{ $r['name'] ?? ($r['host'] ?? '@') }}{{ $r['value'] ?? '' }}
+ @endif +
+
+ @csrf + + DNS changes can take time to propagate. +
+ @else +
+

This domain is verified. Create a mailbox on it.

+
+
SPF {{ ($domain['spf'] ?? false) ? '✓' : '✗' }}
+
DKIM {{ ($domain['dkim'] ?? false) ? '✓' : '✗' }}
+
DMARC {{ ($domain['dmarc'] ?? false) ? '✓' : '✗' }}
+
+
+ @endunless + +
+ @csrf @method('DELETE') + +
+
+@endsection diff --git a/resources/views/email/mailboxes/create.blade.php b/resources/views/email/mailboxes/create.blade.php new file mode 100644 index 0000000..ec3f8ba --- /dev/null +++ b/resources/views/email/mailboxes/create.blade.php @@ -0,0 +1,95 @@ +@extends('layouts.email') +@section('title', 'New mailbox — Ladill Email') +@section('content') +
+

Create a mailbox

+ @if(empty($domains)) +
+ You need a verified domain first. Set up a domain. +
+ @else + @php $fmt = fn ($m) => $quota['currency'].' '.number_format($m / 100, 2); @endphp +
+ {{-- Pricing banner (reacts to the selected storage plan) --}} + + + +
+ @csrf +
+ + +
+
+ + + @error('local_part')

{{ $message }}

@enderror +
+
+ + +
+ + {{-- Storage plan (sets quota + price) --}} +
+ +
+ @foreach($quota['tiers'] as $t) + + @endforeach +
+ @error('quota_mb')

{{ $message }}

@enderror +
+ +
+
+ + +
+
+ + +
+
+ @error('password')

{{ $message }}

@enderror + + +
+
+ @endif +
+@endsection diff --git a/resources/views/email/mailboxes/index.blade.php b/resources/views/email/mailboxes/index.blade.php new file mode 100644 index 0000000..284eeac --- /dev/null +++ b/resources/views/email/mailboxes/index.blade.php @@ -0,0 +1,32 @@ +@extends('layouts.email') +@section('title', 'Mailboxes — Ladill Email') +@section('content') +
+
+

Mailboxes

+ New mailbox +
+ @if($error)
{{ $error }}
@endif + + @if(empty($mailboxes)) +
+

No mailboxes yet

+

Verify a domain, then create a mailbox like you@yourdomain.com.

+ Create mailbox +
+ @else + + @endif +
+@endsection diff --git a/resources/views/email/mailboxes/show.blade.php b/resources/views/email/mailboxes/show.blade.php new file mode 100644 index 0000000..79c78f8 --- /dev/null +++ b/resources/views/email/mailboxes/show.blade.php @@ -0,0 +1,83 @@ +@extends('layouts.email') +@section('title', $mailbox['address'].' — Ladill Email') +@section('content') +@php $host = 'mail.'.config('app.platform_domain'); @endphp +
+
+ Mailboxes/ + {{ $mailbox['address'] }} +
+
+

{{ $mailbox['address'] }}

+ {{ $mailbox['status'] ?? '' }} +
+ +
+

Connection settings

+

Use these in any mail client, or just open webmail.

+
+
Username
{{ $mailbox['address'] }}
+
IMAP
{{ $host }}:993 (SSL)
+
SMTP
{{ $host }}:587 (STARTTLS)
+
+ Open Webmail ↗ +
+ + @php + $quotaMb = (int) ($mailbox['quota_mb'] ?? 0); + $usedBytes = (int) ($mailbox['used_bytes'] ?? 0); + $price = \App\Support\MailboxPricing::priceMinorFor($quotaMb); + $tiers = \App\Support\MailboxPricing::tiers(); + $maxMb = collect($tiers)->max('mb'); + $pct = $quotaMb > 0 ? min(100, (int) round($usedBytes / ($quotaMb * 1048576) * 100)) : 0; + $fmt = fn ($m) => config('email.currency', 'GHS').' '.number_format($m / 100, 2); + @endphp +
+
+
+

Storage plan

+

+ {{ \App\Support\MailboxPricing::label($quotaMb) }} + @if($price === 0) + Free + @else + {{ $fmt($price) }}/month + @endif +

+
+ @if($quotaMb < $maxMb) + Upgrade + @else + Top plan + @endif +
+ @if($quotaMb > 0) +
+
+
+
+

{{ number_format($usedBytes / 1048576, 0) }} MB of {{ \App\Support\MailboxPricing::label($quotaMb) }} used ({{ $pct }}%)

+
+ @endif +
+ +
+

Reset password

+
+ @csrf @method('PATCH') + + + +
+
+ +
+

Delete mailbox

+

This permanently removes the mailbox and its email.

+
+ @csrf @method('DELETE') + +
+
+
+@endsection diff --git a/resources/views/email/mailboxes/upgrade.blade.php b/resources/views/email/mailboxes/upgrade.blade.php new file mode 100644 index 0000000..e6c6a4d --- /dev/null +++ b/resources/views/email/mailboxes/upgrade.blade.php @@ -0,0 +1,52 @@ +@extends('layouts.email') +@section('title', 'Upgrade storage — Ladill Email') +@section('content') +@php $fmt = fn ($m) => $currency.' '.number_format($m / 100, 2); @endphp +
+
+ {{ $mailbox['address'] }}/ + Upgrade +
+

Upgrade storage

+

+ Currently on {{ \App\Support\MailboxPricing::label($currentMb) }}. Pick a larger plan — billed monthly from your wallet. +

+ +
+
+ New plan: /month, charged now from your wallet + (balance: ). + Top up +
+ +
+ @csrf @method('PATCH') +
+ +
+ @foreach($tiers as $t) + + @endforeach +
+ @error('quota_mb')

{{ $message }}

@enderror +
+ +
+
+
+@endsection diff --git a/resources/views/email/signed-out.blade.php b/resources/views/email/signed-out.blade.php new file mode 100644 index 0000000..02f453e --- /dev/null +++ b/resources/views/email/signed-out.blade.php @@ -0,0 +1,17 @@ + + + + + Signed out — Ladill Email + @include('partials.favicon') + @vite(['resources/css/app.css']) + + + +
+ Ladill Email +

You’ve been signed out. Redirecting…

+ Go to ladill.com +
+ + diff --git a/resources/views/hosting/account.blade.php b/resources/views/hosting/account.blade.php new file mode 100644 index 0000000..f2a9431 --- /dev/null +++ b/resources/views/hosting/account.blade.php @@ -0,0 +1,694 @@ + + Hosting Account Details + + @php + $statusColors = [ + 'active' => 'bg-emerald-50 text-emerald-700', + 'suspended' => 'bg-red-50 text-red-700', + 'pending' => 'bg-amber-50 text-amber-700', + 'provisioning' => 'bg-blue-50 text-blue-700', + 'failed' => 'bg-red-50 text-red-700', + ]; + + $statusLabels = [ + 'active' => 'Active', + 'suspended' => 'Suspended', + 'pending' => 'Pending Setup', + 'provisioning' => 'Setting Up...', + 'failed' => 'Setup Failed', + ]; + + $formErrors = $errors ?? new \Illuminate\Support\ViewErrorBag; + $addDomainAction = \Illuminate\Support\Facades\Route::has('hosting.panel.domains.add') + ? route('hosting.panel.domains.add', $account) + : '#'; + $panelIndexUrl = \Illuminate\Support\Facades\Route::has('hosting.panel.index') + ? route('hosting.panel.index', $account) + : '#'; + $panelSettingsUrl = \Illuminate\Support\Facades\Route::has('hosting.panel.settings') + ? route('hosting.panel.settings', $account) + : '#'; + $resourceStatusColors = [ + 'active' => 'bg-emerald-50 text-emerald-700', + 'throttled' => 'bg-amber-50 text-amber-700', + 'suspended' => 'bg-red-50 text-red-700', + ]; + @endphp + + @php + $ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all(); + $oldDomain = trim((string) old('domain', '')); + $initialDomainSource = $ownedDomains->isEmpty() + ? 'external' + : ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill'); + $selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; + $externalFallback = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; + @endphp + + +
+ {{-- Breadcrumb --}} + + + {{-- Expired Account Banner --}} + @if ($account->isExpired() && $account->isInGracePeriod()) +
+
+ +
+

Hosting Account Expired

+

Your hosting expired on {{ $account->expires_at->format('M d, Y') }}. Your site is offline but your files are safe. Renew before {{ $account->gracePeriodEndsAt()->format('M d, Y') }} to avoid automatic deletion.

+
+
+
+ @endif + + {{-- Pending Setup Banner --}} + @if ($account->status === 'pending') +
+
+ +
+

Setting Up Your Hosting

+

Your hosting account is being set up on our servers. This usually takes 1-2 minutes. Please refresh this page shortly to check the status.

+
+
+
+ @endif + + {{-- Provisioning Banner --}} + @if ($account->status === 'provisioning') +
+
+ +
+

Provisioning In Progress

+

We're creating your hosting environment, setting up PHP, and configuring your server. This typically takes 1-3 minutes. Please refresh this page to check progress.

+
+
+
+ @endif + + {{-- Failed Setup Banner --}} + @if ($account->status === 'failed') +
+
+ +
+

Setup Failed

+

There was a problem setting up your hosting account. Our team has been notified and will resolve this shortly. If you need immediate assistance, please open a support ticket.

+
+
+
+ @endif + + {{-- Hero Card --}} +
+
+
+
+
+ + + +
+
+

Hosting Account

+
+ {{ $account->primary_domain ?: $account->username }} + + {{ $statusLabels[$account->status ?? 'Pending'] }} + + + {{ ucfirst($account->resource_status ?? 'active') }} resources + +
+

Created {{ $account->created_at->format('M d, Y') }} + @if ($account->expires_at) · Expires {{ $account->expires_at->format('M d, Y') }} @endif +

+
+
+
+ @if ($account->assignedDurationMonths() && !empty($renewalOptions) && ($account->isExpired() || $account->expires_at?->isBefore(now()->addDays(30)))) + + @endif + + @if ($canLinkMoreDomains) + + @endif +
+
+
+ + @if (!empty($upsellRecommendations)) +
+
+
+

Recommended Next Steps

+

These suggestions are based on how this account is being used right now.

+
+ {{ count($upsellRecommendations) }} suggestion{{ count($upsellRecommendations) === 1 ? '' : 's' }} +
+
+ @foreach ($upsellRecommendations as $recommendation) +
+
+
+

{{ $recommendation['title'] }}

+

{{ $recommendation['message'] }}

+
+ + {{ $recommendation['type'] === 'plan_upgrade' ? 'Upgrade' : 'Add-on' }} + +
+ + @if (($recommendation['type'] ?? null) === 'plan_upgrade' && !empty($recommendation['target_product_id'])) +
+ @csrf + + +
+ @elseif (!empty($recommendation['cta_url'])) + + @endif +
+ @endforeach +
+
+ @endif + + {{-- Info Cards Grid --}} +
+ {{-- Account Details Card --}} +
+
+
+ +
+

Account Details

+
+
+
+ Username + {{ $account->username }} +
+
+ Node IP + {{ $account->node?->ip_address ?? '161.97.138.149' }} +
+ @if ($account->product) +
+ Product + {{ $account->product->name }} +
+ @endif +
+ Email Usage + + {{ $emailUsage['mailbox_count'] }} + @if ($emailUsage['free_allowance'] === null) + / Unlimited included + @else + / {{ $emailUsage['free_allowance'] }} included + @endif + +
+ @if ($emailUsage['extra_count'] > 0) +
+ Extra Emails: {{ $emailUsage['extra_count'] }} x GHS 5 = GHS {{ number_format($emailUsage['extra_total'], 2) }} +
+ @endif +
+
+ + {{-- Resources Card --}} +
+
+
+ +
+

Resources

+
+
+ @php + $diskLimit = max(($account->allocated_disk_gb ?: ($account->product?->disk_gb ?? 10)), 1) * 1073741824; + $bandwidthLimit = $account->resource_limits['bandwidth_limit_bytes'] ?? 107374182400; // 100GB default + $diskUsed = $account->disk_used_bytes ?? 0; + $bandwidthUsed = $account->bandwidth_used_bytes ?? 0; + $diskPercent = $diskLimit > 0 ? ($diskUsed / $diskLimit) * 100 : 0; + $bandwidthPercent = $bandwidthLimit > 0 ? ($bandwidthUsed / $bandwidthLimit) * 100 : 0; + @endphp +
+
+ Disk Space + {{ number_format($diskUsed / 1073741824, 1) }} GB / {{ number_format($diskLimit / 1073741824, 1) }} GB +
+
+
+
+
+
+
+ Bandwidth + {{ number_format($bandwidthUsed / 1073741824, 1) }} GB / {{ number_format($bandwidthLimit / 1073741824, 1) }} GB +
+
+
+
+
+ @if ($account->php_version) +
+ PHP Version + {{ $account->php_version }} +
+ @endif +
+ CPU + {{ number_format((float) ($account->cpu_usage_percent ?? 0), 1) }}% / {{ $account->cpu_limit_percent ?? '-' }}% +
+
+ Memory + {{ $account->memory_used_mb ?? 0 }} MB / {{ $account->memory_limit_mb ?? '-' }} MB +
+
+ Processes + {{ $account->process_count ?? 0 }} / {{ $account->process_limit ?? '-' }} +
+
+ Inodes + {{ number_format((int) ($account->inode_count ?? 0)) }} / {{ number_format((int) ($account->inode_limit ?? 0)) }} +
+
+
+ + {{-- Features Card --}} +
+
+
+ +
+

Features

+
+
+ @php + $features = $account->features_enabled ?? []; + $defaultFeatures = ['SFTP Access', 'FTP Access', 'Email Accounts', 'MySQL Database']; + $displayFeatures = !empty($features) ? $features : $defaultFeatures; + @endphp + @foreach ($displayFeatures as $feature) +
+
+ {{ $feature }} +
+ @endforeach +
+
+ + {{-- Health Card --}} +
+
+
+ +
+

Health

+
+
+
+ Status + + {{ ucfirst($account->status ?? 'Unknown') }} + +
+ @if ($account->provisioned_at) +
+ Provisioned + {{ $account->provisioned_at->format('M d, Y') }} +
+ @endif + @if ($account->suspended_at) +
+ Suspended + {{ $account->suspended_at->format('M d, Y') }} +
+ @endif + @if (!empty($account->suspension_reason)) +
+ {{ $account->suspension_reason }} +
+ @endif + @if ($account->uploads_restricted) +
+ Uploads are temporarily restricted because the inode limit has been reached. +
+ @endif +
+
+
+ + {{-- Domain Card --}} +
+
+
+
+ +
+

Domains

+
+ @if ($canLinkMoreDomains) + + @endif +
+
+ @if ($linkedSites->isNotEmpty()) +
+ @foreach ($linkedSites as $site) +
+
+
+ {{ $site->domain }} + @if ($site->type === 'primary') + Primary + @endif + @if ($site->ssl_enabled) + SSL + @endif +
+

{{ $site->document_root }}

+
+ + {{ ucfirst($site->status) }} + +
+ @endforeach +
+

+ {{ $linkedSites->count() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use. +

+ @elseif ($account->primary_domain) +
+ {{ $account->primary_domain }} + + Active + +
+

Primary domain for this hosting account.

+ @else +
+

No domain linked

+

Link a domain to use your hosting account.

+ @if ($canLinkMoreDomains) + + @endif +
+ @endif +
+
+ + {{-- Domain Linking Modal --}} + + + {{-- Renewal Duration Modal --}} + @if (!empty($renewalOptions)) + + @endif + + @include('partials.paystack-sheet') + + {{-- Control Panel Modal --}} + +
+
diff --git a/resources/views/hosting/account/billing.blade.php b/resources/views/hosting/account/billing.blade.php new file mode 100644 index 0000000..8bdd60c --- /dev/null +++ b/resources/views/hosting/account/billing.blade.php @@ -0,0 +1,28 @@ +@extends('layouts.hosting') +@section('title', 'Billing — Ladill Hosting') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Billing

+

Your Ladill wallet funds hosting renewals and upgrades.

+ +
+
+

Wallet balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+
+

Spent on hosting

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+ +

Hosting plan renewals and upgrades are billed from your Ladill wallet. Manage individual accounts from the dashboard or account overview pages.

+

Keep your wallet funded to avoid hosting interruption.

+
+@endsection diff --git a/resources/views/hosting/account/developers.blade.php b/resources/views/hosting/account/developers.blade.php new file mode 100644 index 0000000..5afe1e2 --- /dev/null +++ b/resources/views/hosting/account/developers.blade.php @@ -0,0 +1,65 @@ + +
+

Developers

+

API tokens to manage your hosting programmatically.

+ + @if($newToken) +
+

Your new token — copy it now

+

This is the only time it will be shown.

+
+ {{ $newToken }} + +
+
+ @endif + +
+

Create a token

+
+ @csrf +
+ + + @error('name')

{{ $message }}

@enderror +
+ +
+
+ +
+

Your tokens

+ @forelse($tokens as $token) +
+
+

{{ $token->name }}

+

+ Created {{ $token->created_at->diffForHumans() }} · + {{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }} +

+
+
+ @csrf @method('DELETE') + +
+
+ @empty +

No tokens yet.

+ @endforelse +
+ +
+

Quick start

+

Authenticate with a Bearer token. Base URL:

+ {{ $apiBase }} +
curl {{ $apiBase }}/me \
+  -H "Authorization: Bearer <your-token>" \
+  -H "Accept: application/json"
+

Endpoints: GET /me. More coming soon.

+
+
+
diff --git a/resources/views/hosting/account/settings.blade.php b/resources/views/hosting/account/settings.blade.php new file mode 100644 index 0000000..ac142d8 --- /dev/null +++ b/resources/views/hosting/account/settings.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.hosting') +@section('title', 'Settings — Ladill Hosting') +@section('content') +
+

Settings

+

Hosting notifications and preferences.

+ +
+ @csrf @method('PUT') + +
+

Notifications

+

Where we send hosting renewal reminders, suspension notices, and resource warnings.

+
+ + +
+
+ +
+ +
+ + +
+
+@endsection diff --git a/resources/views/hosting/account/team.blade.php b/resources/views/hosting/account/team.blade.php new file mode 100644 index 0000000..646671d --- /dev/null +++ b/resources/views/hosting/account/team.blade.php @@ -0,0 +1,80 @@ + +
+

Team

+

Invite people to help manage this account’s hosting.

+ + @if($canManage) +
+

Invite a teammate

+
+ @csrf +
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + +
+ +
+

Admins can manage hosting and the team. Members can manage hosting. Invitees join by signing in with that email.

+
+ @endif + +
+

Members

+
    +
  • +
    + {{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }} +
    +

    {{ $account->name ?? $account->email }} (you)

    +

    {{ $account->email }}

    +
    +
    + Owner +
  • + + @forelse($members as $member) +
  • +
    + {{ strtoupper(substr($member->email, 0, 1)) }} +
    +

    {{ $member->member->name ?? $member->email }}

    +

    {{ $member->email }}

    +
    +
    +
    + @if($member->status === 'invited') + Invited + @endif + @if($canManage) +
    + @csrf @method('PATCH') + +
    +
    + @csrf @method('DELETE') + +
    + @else + {{ $member->role }} + @endif +
    +
  • + @empty +
  • No teammates yet.
  • + @endforelse +
+
+
+
diff --git a/resources/views/hosting/account/wallet.blade.php b/resources/views/hosting/account/wallet.blade.php new file mode 100644 index 0000000..693950c --- /dev/null +++ b/resources/views/hosting/account/wallet.blade.php @@ -0,0 +1,27 @@ +@extends('layouts.hosting') +@section('title', 'Wallet — Ladill Hosting') +@section('content') +@php $fmt = fn ($m) => 'GHS '.number_format($m / 100, 2); @endphp +
+

Wallet

+

Your Ladill wallet funds hosting and other Ladill apps.

+ +
+

Balance

+

{{ $fmt($balanceMinor) }}

+ Add funds +
+ +
+
+

Spent on hosting

+

{{ $fmt($spentMinor) }}

+
+
+

Refunded / credited

+

{{ $fmt($creditedMinor) }}

+
+
+

Hosting renewals and upgrades are billed from this wallet.

+
+@endsection diff --git a/resources/views/hosting/accounts-list.blade.php b/resources/views/hosting/accounts-list.blade.php new file mode 100644 index 0000000..f7464aa --- /dev/null +++ b/resources/views/hosting/accounts-list.blade.php @@ -0,0 +1,82 @@ +@extends('layouts.hosting') +@section('title', $title . ' — Ladill Hosting') +@section('content') + +@endsection diff --git a/resources/views/hosting/dashboard.blade.php b/resources/views/hosting/dashboard.blade.php new file mode 100644 index 0000000..07ee685 --- /dev/null +++ b/resources/views/hosting/dashboard.blade.php @@ -0,0 +1,103 @@ +@extends('layouts.hosting') +@section('title', 'Overview — Ladill Hosting') +@section('content') +
+
+
+

Overview

+

Your shared hosting at a glance.

+
+ + New hosting + +
+ +
+ @foreach([ + ['Active accounts', $activeCount, route('hosting.accounts.index')], + ['Expiring (30 days)', $expiringCount, route('hosting.accounts.index')], + ['Pending orders', $pendingOrderCount, route('hosting.single-domain')], + ['Linked domains', $linkedDomains, route('hosting.accounts.index')], + ] as [$label, $value, $link]) + +

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ +
+
+

Hosting accounts

+ @if ($accounts->count() > 5) + View all + @endif +
+ @if ($recent->isEmpty()) +
+

No hosting accounts yet

+

Choose a plan to get started — single domain, multi domain, or WordPress hosting.

+ +
+ @else + + @endif +
+ + @if ($pendingOrders->isNotEmpty()) + + @endif +
+@endsection diff --git a/resources/views/hosting/order.blade.php b/resources/views/hosting/order.blade.php new file mode 100644 index 0000000..28f0ef4 --- /dev/null +++ b/resources/views/hosting/order.blade.php @@ -0,0 +1,255 @@ + + {{ $order->domain_name }} - Order Details + + @php + $statusColors = [ + 'pending_payment' => 'bg-amber-50 text-amber-700 border-amber-200', + 'pending_approval' => 'bg-amber-50 text-amber-700 border-amber-200', + 'approved' => 'bg-blue-50 text-blue-700 border-blue-200', + 'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200', + 'active' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'suspended' => 'bg-red-50 text-red-700 border-red-200', + 'cancelled' => 'bg-gray-100 text-gray-500 border-gray-200', + 'expired' => 'bg-gray-100 text-gray-500 border-gray-200', + 'failed' => 'bg-red-50 text-red-700 border-red-200', + ]; + + $statusLabels = collect([ + 'pending_payment', 'pending_approval', 'approved', 'provisioning', + 'active', 'suspended', 'cancelled', 'expired', 'failed', + ])->mapWithKeys(fn (string $status) => [ + $status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status), + ])->all(); + $statusColors['approved'] = 'bg-amber-50 text-amber-700 border-amber-200'; + + $backRoute = match ($order->product?->type) { + 'single_domain' => route('hosting.single-domain'), + 'multi_domain' => route('hosting.multi-domain'), + 'wordpress' => route('hosting.wordpress'), + default => route('hosting.single-domain'), + }; + @endphp + +
+ {{-- Page Header --}} +
+ +
+ + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + +
+
+ +
+ {{-- Main Content --}} +
+ {{-- Order Details --}} +
+
+

Order Details

+
+
+
+ Domain + {{ $order->domain_name }} +
+
+ Plan + {{ $order->product?->name ?? '-' }} +
+
+ Billing Cycle + {{ ucfirst($order->billing_cycle) }} +
+
+ Amount + {{ strtoupper($order->currency) }} {{ number_format($order->amount_paid, 2) }} +
+
+ Created + {{ $order->created_at->format('M d, Y H:i') }} +
+ @if ($order->expires_at) +
+ Expires + {{ $order->expires_at->format('M d, Y') }} +
+ @endif +
+
+ + {{-- Server Details (if active) --}} + @if ($order->isActive() && ($order->server_ip || $order->server_username || $order->control_panel_url)) +
+
+

Server Details

+
+
+ @if ($order->server_ip) +
+ IP Address + {{ $order->server_ip }} +
+ @endif + @if ($order->server_username) +
+ Username + {{ $order->server_username }} +
+ @endif + @if ($order->hostingNode) +
+ Server + {{ $order->hostingNode->hostname }} +
+ @endif + @if ($order->control_panel_url) +
+ Control Panel + + Open cPanel → + +
+ @endif +
+
+ @endif + + {{-- Plan Resources --}} + @if ($order->product) +
+
+

Plan Resources

+
+
+
+

{{ $order->product->formatDiskSize() }}

+

Storage

+
+
+

{{ $order->product->formatBandwidth() }}

+

Bandwidth

+
+
+

{{ $order->product->max_domains ?? '∞' }}

+

Domains

+
+
+

{{ $order->product->max_databases ?? '∞' }}

+

Databases

+
+
+
+ @endif +
+ + {{-- Sidebar --}} +
+ {{-- Quick Actions --}} +
+
+

Quick Actions

+
+
+ @if ($order->control_panel_url && $order->isActive()) + + + Open Control Panel + + @endif + + + Get Support + + @if ($order->canBeCancelled()) + + @endif +
+
+ + {{-- Status Timeline --}} +
+
+

Status

+
+
+
+
+
+ +
+
+

Order Placed

+

{{ $order->created_at->format('M d, Y H:i') }}

+
+
+
+
+ @if ($order->approved_at) + + @else +
+ @endif +
+
+

Approved

+ @if ($order->approved_at) +

{{ $order->approved_at->format('M d, Y H:i') }}

+ @endif +
+
+
+
+ @if ($order->provisioned_at) + + @else +
+ @endif +
+
+

Provisioned

+ @if ($order->provisioned_at) +

{{ $order->provisioned_at->format('M d, Y H:i') }}

+ @endif +
+
+
+
+
+
+
+ + {{-- Cancel Modal --}} + +
+
diff --git a/resources/views/hosting/panel/apps.blade.php b/resources/views/hosting/panel/apps.blade.php new file mode 100644 index 0000000..b7a6053 --- /dev/null +++ b/resources/views/hosting/panel/apps.blade.php @@ -0,0 +1,551 @@ + + Application Installer - {{ $account->username }} + Application Installer + +
+ @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + + @php + $sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning'); + @endphp + + @if($sslProvisioningSites->isNotEmpty()) +
+
+
+ + + + +
+
+

SSL Certificate Provisioning

+

+ SSL certificates are being provisioned for + {{ $sslProvisioningSites->pluck('domain')->join(', ') }}. + Please wait until this completes before installing applications. This usually takes 1-2 minutes. +

+
+
+
+ @endif + + {{-- Active Installations In Progress (at top for visibility) --}} + @php + $pendingInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id')) + ->where('status', 'installing') + ->with('site') + ->get(); + @endphp + @if($pendingInstallations->count() > 0) +
+ +
+ @endif + + {{-- Failed Installations --}} + @php + $failedInstallations = \App\Models\AppInstallation::whereIn('hosted_site_id', $account->sites->pluck('id')) + ->where('status', 'failed') + ->with('site') + ->get(); + @endphp + @if($failedInstallations->count() > 0) +
+
+

+ + Failed Installations +

+
+
+ @foreach($failedInstallations as $failed) +
+
+
+ {{ ucfirst($failed->app_type) }} +
+
+

{{ ucfirst($failed->app_type) }} on {{ $failed->site->domain }}

+

{{ $failed->error_message ?: 'Installation failed. Please try again.' }}

+
+
+ @csrf + + + +
+
+
+ @endforeach +
+
+ @endif + + @if(!empty($isWordPressPlan)) + {{-- WordPress-Only Installer --}} +
+
+
+
+
+ @include('partials.wordpress-icon', ['class' => 'h-10 w-10']) +
+

Install WordPress

+

Your hosting plan is optimised for WordPress. Install it in one click with a database and admin credentials set up automatically.

+
+
+
+ +
+ @csrf + + +
+
+ + @if($account->sites->count() > 0) + + @else +

You need to add a domain first.

+ @endif +
+
+ + +
+
+ +
+ + +

Install in a subfolder instead of the domain root.

+
+ +
+ +
+
+
+ @else + {{-- Available Applications - Modern Grid --}} +
+
+

Available Applications

+

One-click installation for popular CMS platforms and frameworks.

+
+
+
+ @foreach($availableApps as $app) +
+
+
+ {{ $app['name'] }} +
+

{{ $app['name'] }}

+

{{ $app['description'] }}

+
+ {{ ucfirst($app['category']) }} + @if(in_array($app['id'], ['wordpress', 'joomla', 'drupal', 'opencart', 'magento', 'laravel'])) + PHP + @elseif($app['id'] === 'nodejs') + JavaScript + @elseif($app['id'] === 'python') + Python + @endif +
+
+
+ @endforeach +
+
+
+ + {{-- Install Application Form --}} +
+
+
+ +
+
+

Install New Application

+

CMS applications automatically provision a database and generate admin credentials.

+
+
+
+ @csrf +
+
+ + +
+
+ + @if($account->sites->count() > 0) + + @else +

You need to add a domain first.

+ @endif +
+
+ {{-- PHP Version Selector (only for PHP apps) --}} +
+ + +

+ Magento requires PHP 8.1, 8.2, or 8.3 + Select the PHP version for your application. Most apps work best with PHP 8.3 or 8.4. +

+
+
+ + +

Install in a subfolder instead of the domain root.

+
+
+ +
+
+
+ @endif + + {{-- Current Installations --}} + @php + $installedSites = $account->sites->filter(fn($site) => $site->installed_app)->load('appInstallation'); + @endphp + @if($installedSites->count() > 0) +
+
+

Current Installations

+

View your application credentials and access details below.

+
+
+ @foreach($installedSites as $site) +
+
+
+
+ {{ ucfirst($site->installed_app) }} + +
+
+

{{ ucfirst($site->installed_app) }}

+

{{ $site->domain }}

+
+
+
+ @if($site->installed_app_version) + v{{ $site->installed_app_version }} + @endif + Active + Visit Site → + @if($site->appInstallation) + + @endif +
+ @csrf + @method('DELETE') + +
+
+
+ + {{-- Credentials Panel --}} + @if($site->appInstallation) +
+
+ {{-- Admin Credentials --}} +
+

+ + Admin Access +

+
+
+ + +
+
+ +
+ {{ $site->appInstallation->admin_username }} + +
+
+
+ + @if($site->appInstallation->admin_password) +
+ + + +
+ @else +

Use the application's password reset if needed

+ @endif +
+
+
+ + {{-- Database Credentials --}} + @if($site->appInstallation->database_name) +
+

+ + Database Access +

+
+
+ +
+ {{ $site->appInstallation->database_name }} + +
+
+
+ +
+ {{ $site->appInstallation->database_username }} + +
+
+
+ + @if($site->appInstallation->database_password) +
+ + + +
+ @else +

Not available

+ @endif +
+
+
+ @endif +
+ +
+

+ + Keep these credentials secure. We recommend changing your password after first login. +

+
+
+ @endif +
+ @endforeach +
+
+ @endif + +
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/hosting/panel/cron.blade.php b/resources/views/hosting/panel/cron.blade.php new file mode 100644 index 0000000..6a0668c --- /dev/null +++ b/resources/views/hosting/panel/cron.blade.php @@ -0,0 +1,136 @@ + + Cron Jobs - {{ $account->username }} + Cron Jobs + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+

Add Cron Job

+
+ @csrf +
+
+ + +

0-59 or *

+
+
+ + +

0-23 or *

+
+
+ + +

1-31 or *

+
+
+ + +

1-12 or *

+
+
+ + +

0-6 or *

+
+
+
+ + +

Use full paths for commands and scripts

+
+ +
+
+ +
+

Common Schedules

+
+
+

Every minute

+ * * * * * +
+
+

Every 5 minutes

+ */5 * * * * +
+
+

Every hour

+ 0 * * * * +
+
+

Daily at midnight

+ 0 0 * * * +
+
+

Weekly (Sunday)

+ 0 0 * * 0 +
+
+

Monthly

+ 0 0 1 * * +
+
+
+ +
+
+

Active Cron Jobs

+
+ @if(count($cronJobs)) + + + + + + + + + + @foreach($cronJobs as $job) + + + + + + @endforeach + +
ScheduleCommandActions
+ {{ $job['schedule'] }} + + {{ $job['command'] }} + +
+ @csrf + @method('DELETE') + + +
+
+ @else +
+ +

No cron jobs configured yet.

+
+ @endif +
+
+
diff --git a/resources/views/hosting/panel/databases.blade.php b/resources/views/hosting/panel/databases.blade.php new file mode 100644 index 0000000..685cf44 --- /dev/null +++ b/resources/views/hosting/panel/databases.blade.php @@ -0,0 +1,148 @@ + + Databases - {{ $account->username }} + Databases + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+

Create New Database

+
+ @csrf +
+ +
+ {{ substr($account->username, 0, 8) }}_ + +
+
+ +
+
+ +
+
+

Your Databases

+
+ @if($account->databases->count()) + + + + + + + + + + + @foreach($account->databases as $database) + + + + + + + @endforeach + +
Database NameUsernameHostActions
+ {{ $database->name }} + + {{ $database->username }} + + {{ $database->host }} + +
+ @if($phpMyAdminUrl) + + + phpMyAdmin + + @endif + +
+ @csrf + @method('DELETE') + +
+
+
+ @else +
+ +

No databases yet. Create one above.

+
+ @endif +
+ +
+

Connection Information

+
+
+

Host

+

localhost

+
+
+

Port

+

3306

+
+
+

External Access

+

Not available (localhost only)

+
+
+

phpMyAdmin

+ @if($phpMyAdminUrl) + + Open phpMyAdmin + + +

Use your database username and password to log in.

+ @else +

phpMyAdmin is not configured for this server.

+ @endif +
+
+
+
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/hosting/panel/domains.blade.php b/resources/views/hosting/panel/domains.blade.php new file mode 100644 index 0000000..151d2b5 --- /dev/null +++ b/resources/views/hosting/panel/domains.blade.php @@ -0,0 +1,256 @@ + + Domains - {{ $account->username }} + Domains + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + + @php + $ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all(); + $oldDomain = trim((string) old('domain', '')); + $initialDomainSource = $ownedDomains->isEmpty() + ? 'external' + : ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill'); + $selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; + $externalFallbackDomain = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : ''; + @endphp + +
+

Add Domain

+
+ @csrf + + + {{-- Domain Source Toggle --}} +
+
+ +
+ + +
+
+ + {{-- Ladill (Owned) Domains --}} +
+ @if($ownedDomains->isNotEmpty()) + +

+ Don't have a domain? + Purchase one first +

+ @else +
+ No domains found in your account. + Register a domain +
+ @endif +
+ + {{-- External Domain --}} +
+ +

Enter your domain without www or http (e.g., example.com)

+
+ + @error('domain') +

{{ $message }}

+ @enderror +
+ + {{-- Onboarding Mode (External only) --}} +
+ +
+ + + +
+ @error('onboarding_mode') +

{{ $message }}

+ @enderror +
+ + {{-- Document Root (optional, both flows) --}} +
+ + +

Leave empty to use default: public_html/domain.com

+
+ + +
+
+ +
+
+

Your Domains

+
+ @if($account->sites->count()) + + + + + + + + + + + @foreach($account->sites as $site) + + + + + + + @endforeach + +
DomainDocument RootStatusActions
+
+ {{ $site->domain }} + + + +
+
+ {{ $site->document_root }} + + + {{ ucfirst($site->status) }} + + + @if((int) $account->user_id === (int) auth()->id()) +
+ @csrf + @method('DELETE') + +
+ @else + Owner only + @endif +
+ @else +
+ @include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300']) +

No addon domains yet. Add one above.

+
+ @endif +
+ + @php + $serverIp = $account->node?->ip_address ?? '161.97.138.149'; + @endphp + + {{-- DNS Quick Reference --}} +
+

DNS Quick Reference

+

Use one of these methods to connect an external domain:

+ +
+
+
+

Nameservers

+ Recommended +
+
+
+ ns1.ladill.com + +
+
+ ns2.ladill.com + +
+
+
+ +
+

A Records

+
+
+ Type + Name + Value +
+
+ A + @ + {{ $serverIp }} +
+
+ A + www + {{ $serverIp }} +
+
+

DNS changes can take up to 24–48 hours.

+
+
+
+
+
diff --git a/resources/views/hosting/panel/files.blade.php b/resources/views/hosting/panel/files.blade.php new file mode 100644 index 0000000..75d668d --- /dev/null +++ b/resources/views/hosting/panel/files.blade.php @@ -0,0 +1,782 @@ + + File Manager - {{ $account->username }} + File Manager + +
+ {{-- Action Feedback --}} + + +
+
+
+

+

+
+ +
+
+ + {{-- Toolbar --}} +
+ + + +
+ + {{-- Upload Progress --}} +
+
+ Uploading + +
+
+
+
+

Processing file on server...

+
+ + {{-- Extraction Progress --}} +
+
+ + + + +
+ Extracting +

This may take a while for large archives...

+
+
+
+ + {{-- Bulk Action Bar --}} +
+ +
+ + + + + +
+ +
+
+ + {{-- Breadcrumbs --}} +
+ @foreach($breadcrumbs as $index => $crumb) + @if($index > 0) + + @endif + {{ $crumb['name'] }} + @endforeach +
+ + {{-- File Table --}} +
+ + + + + + + + + + + + @forelse($files as $file) + + + + + + + + @empty + + @endforelse + +
NameSizeModifiedActions
+
+ @if($file['type'] === 'folder') + + {{ $file['name'] }} + @else + + {{ $file['name'] }} + @endif +
+
{{ $file['size_formatted'] }}{{ $file['modified'] }} +
+ @if($file['type'] === 'file') + @if(in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['zip', 'tar', 'tgz']) || str_ends_with(strtolower($file['name']), '.tar.gz') || str_ends_with(strtolower($file['name']), '.tar.bz2')) + + @endif + + @endif + + +
+
This folder is empty
+
+ + {{-- Create Modal --}} + + + {{-- Edit Modal --}} + + + {{-- Rename Modal --}} + + + {{-- Move/Copy Modal --}} + + + {{-- Compress Modal --}} + + + {{-- Chmod Modal --}} + +
+ + @push('scripts') + + @endpush +
diff --git a/resources/views/hosting/panel/index.blade.php b/resources/views/hosting/panel/index.blade.php new file mode 100644 index 0000000..b385d33 --- /dev/null +++ b/resources/views/hosting/panel/index.blade.php @@ -0,0 +1,156 @@ + + Hosting Panel - {{ $account->username }} + Overview - {{ $account->username }} + +
+ + @php + $sslProvisioningSites = $account->sites->filter(fn($site) => $site->ssl_status === 'provisioning'); + @endphp + + @if($sslProvisioningSites->isNotEmpty()) +
+
+
+ + + + +
+
+

SSL Certificate Provisioning

+

+ SSL certificates are being provisioned for + {{ $sslProvisioningSites->pluck('domain')->join(', ') }}. + Please wait until this completes before installing applications. This usually takes 1-2 minutes. +

+
+
+
+ @endif + +
+
+
+
+ +
+
+

Disk Usage

+

{{ number_format(($stats['disk_used_bytes'] ?? 0) / 1048576, 2) }} MB

+
+
+
+
+
+
+ +
+
+

Files

+

{{ number_format(($stats['file_size_bytes'] ?? 0) / 1048576, 2) }} MB

+
+
+
+ @can('manageDatabases', $account) +
+
+
+ +
+
+

Databases

+

{{ $account->databases->count() }}

+
+
+
+ @endcan +
+
+
+ @include('components.icons.domain-globe', ['class' => 'h-5 w-5']) +
+
+

Domains

+

{{ $account->sites->count() }}

+
+
+
+
+ + + + +
+

SFTP Access

+
+
+

Host

+

{{ $account->node?->ip_address ?? '161.97.138.149' }}

+
+
+

Port

+

22

+
+
+

Username

+

{{ $account->username }}

+
+
+

Password

+ @can('changePassword', $account) +

Set in Settings

+ @else +

Use your SSH key in Settings

+ @endcan +
+
+
+
+
diff --git a/resources/views/hosting/panel/logs.blade.php b/resources/views/hosting/panel/logs.blade.php new file mode 100644 index 0000000..5785a63 --- /dev/null +++ b/resources/views/hosting/panel/logs.blade.php @@ -0,0 +1,80 @@ + + Error Logs - {{ $account->username }} + Error Logs + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+ + +
+ + +
+ +
+ @csrf + + +
+
+ +
+
+
+

{{ $logType === 'error' ? 'Error Log' : 'Access Log' }}

+

{{ $logFilePath }}

+
+ + + Refresh + +
+
+
{{ $logContent }}
+
+
+ +
+

Log File Locations

+
+
+

Error Log

+ {{ $errorLogPath }} +
+
+

Access Log

+ {{ $accessLogPath }} +
+
+
+
+
diff --git a/resources/views/hosting/panel/partials/sidebar.blade.php b/resources/views/hosting/panel/partials/sidebar.blade.php new file mode 100644 index 0000000..15949bd --- /dev/null +++ b/resources/views/hosting/panel/partials/sidebar.blade.php @@ -0,0 +1,220 @@ +@php + $currentRoute = request()->route()->getName(); + $panelRuntime = app(\App\Services\Hosting\PanelRuntimeResolver::class)->forSubject($account); + $currentUser = auth()->user(); + + $navGroups = [ + 'main' => [ + [ + 'name' => 'Overview', + 'route' => route('hosting.panel.index', $account), + 'active' => $currentRoute === 'hosting.panel.index', + 'capability' => null, + 'permission' => 'viewPanel', + 'icon' => '', + ], + [ + 'name' => 'File Manager', + 'route' => route('hosting.panel.files', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.files'), + 'capability' => 'files', + 'permission' => 'manageFiles', + 'icon' => '', + ], + [ + 'name' => 'Databases', + 'route' => route('hosting.panel.databases', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.databases'), + 'capability' => 'databases', + 'permission' => 'manageDatabases', + 'icon' => '', + ], + [ + 'name' => 'Domains', + 'route' => route('hosting.panel.domains', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.domains'), + 'capability' => 'domains', + 'permission' => 'viewDomains', + 'domain_icon' => true, + ], + ], + 'software' => [ + [ + 'name' => 'Terminal', + 'route' => route('hosting.panel.terminal', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.terminal'), + 'capability' => 'terminal', + 'permission' => 'useTerminal', + 'icon' => '', + ], + [ + 'name' => 'PHP Settings', + 'route' => route('hosting.panel.php', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.php'), + 'capability' => 'php', + 'permission' => 'managePhp', + 'icon' => '', + ], + [ + 'name' => 'SSL Certificates', + 'route' => route('hosting.panel.ssl', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.ssl'), + 'capability' => 'ssl', + 'permission' => 'manageSsl', + 'icon' => '', + ], + [ + 'name' => 'App Installer', + 'route' => route('hosting.panel.apps', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.apps'), + 'capability' => 'apps', + 'permission' => 'manageApps', + 'icon' => '', + ], + ], + 'advanced' => [ + [ + 'name' => 'Cron Jobs', + 'route' => route('hosting.panel.cron', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.cron'), + 'capability' => 'cron', + 'permission' => 'manageCron', + 'icon' => '', + ], + [ + 'name' => 'Error Logs', + 'route' => route('hosting.panel.logs', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.logs'), + 'capability' => 'logs', + 'permission' => 'viewLogs', + 'icon' => '', + ], + [ + 'name' => 'Settings', + 'route' => route('hosting.panel.settings', $account), + 'active' => str_starts_with($currentRoute, 'hosting.panel.settings'), + 'capability' => 'settings', + 'permission' => 'viewSettings', + 'icon' => '', + ], + ], + ]; + + foreach ($navGroups as $group => $items) { + $navGroups[$group] = array_values(array_filter($items, static function (array $item) use ($panelRuntime, $currentUser, $account): bool { + $capability = $item['capability'] ?? null; + $permission = $item['permission'] ?? null; + + return ($capability === null || $panelRuntime->supports($capability)) + && ($permission === null || $currentUser?->can($permission, $account)); + })); + } +@endphp + +
+ {{-- Logo/Header --}} +
+
+ + + +
+
+

{{ $panelRuntime->label() }}

+

{{ $account->username }}

+
+ +
+ + {{-- Navigation --}} + + + {{-- Account Info Footer --}} +
+
+
+ + + + {{ $account->node->hostname ?? $account->node->ip_address ?? 'Server' }} +
+
+ + {{ ucfirst($account->status) }} + + @if($account->product) + {{ $account->product->name }} + @endif +
+
+
+
diff --git a/resources/views/hosting/panel/php.blade.php b/resources/views/hosting/panel/php.blade.php new file mode 100644 index 0000000..7e2c0a6 --- /dev/null +++ b/resources/views/hosting/panel/php.blade.php @@ -0,0 +1,91 @@ + + PHP Settings - {{ $account->username }} + PHP Settings + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+

PHP Version

+

Select the PHP version for your hosting account. This will apply to all your domains.

+
+ @csrf +
+ @foreach($availableVersions as $version) + + @endforeach +
+ +
+
+ +
+

PHP Configuration

+

Customize PHP settings for your hosting account. These settings are saved to your .user.ini file.

+
+ @csrf +
+
+ + +

Maximum size for file uploads (1-512 MB)

+
+
+ + +

Maximum size for POST data (1-512 MB)

+
+
+ + +

Maximum memory per script (32-1024 MB)

+
+
+ + +

Maximum script execution time (30-600 seconds)

+
+
+ + +

Maximum number of input variables (1000-10000)

+
+
+ +
+
+ +
+

Custom php.ini

+

You can also edit your .user.ini file directly via the File Manager for additional PHP settings.

+

Location: /public_html/.user.ini

+
+
+
diff --git a/resources/views/hosting/panel/settings.blade.php b/resources/views/hosting/panel/settings.blade.php new file mode 100644 index 0000000..0f07d0f --- /dev/null +++ b/resources/views/hosting/panel/settings.blade.php @@ -0,0 +1,160 @@ + + Settings - {{ $account->username }} + Settings + + @php + $serverIp = $account->node?->ip_address ?? '161.97.138.149'; + $sftpCommand = "sftp {$account->username}@{$serverIp}"; + @endphp + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + + @if($canChangePassword) +
+

SFTP Password

+

Change the shared SFTP password for this hosting account. Team developers do not see or manage this password.

+
+ @csrf +
+ + +
+
+ + +
+ +
+
+ @endif + +
+
+
+

SFTP Key Access

+ @if ($teamMembership) +

Add your SSH public key to connect over SFTP as {{ $account->username }}. Removing you from the team automatically removes this key.

+ @else +

Owners connect with the shared account password. Team developers add their own SSH public key here after they sign in.

+ @endif +
+ + @if ($teamMembership?->ssh_key_installed_at) + + Key active + + @endif +
+ + @if ($teamMembership) +
+ @csrf +
+ + +

Use an OpenSSH public key for SFTP access to this hosting account.

+ +
+ + + + @if ($teamMembership->ssh_key_installed_at) +

Last installed {{ $teamMembership->ssh_key_installed_at->diffForHumans() }}.

+ @endif +
+ + @if ($teamMembership->hasSshAccessKey()) +
+ @csrf + @method('DELETE') + +
+ @endif + @else +
+ Developers on your team use their own SSH keys instead of the shared password, so removing them from the team immediately cuts off SFTP access. +
+ @endif +
+ +
+
+
+ +
+
+

SFTP Connection

+

Connect with any SFTP client

+
+
+ +
+
+ {{ $sftpCommand }} + +
+
+ +
+
+

Host

+

{{ $serverIp }}

+
+
+

Port

+

22

+
+
+

Username

+

{{ $account->username }}

+
+
+

Authentication

+

{{ $teamMembership ? 'SSH key' : 'Password' }}

+
+
+
+ +
+

Account Information

+
+
+

Account Type

+

{{ ucfirst($account->type) }} Hosting

+
+
+

Product

+

{{ $account->product->name ?? 'N/A' }}

+
+
+

PHP Version

+

{{ $account->php_version ?? '8.2' }}

+
+
+

Status

+ + {{ ucfirst($account->status) }} + +
+
+
+
+
diff --git a/resources/views/hosting/panel/ssl.blade.php b/resources/views/hosting/panel/ssl.blade.php new file mode 100644 index 0000000..3750528 --- /dev/null +++ b/resources/views/hosting/panel/ssl.blade.php @@ -0,0 +1,100 @@ + + SSL Certificates - {{ $account->username }} + SSL Certificates + +
+ @if(session('success')) +
+

{{ session('success') }}

+
+ @endif + + @if(session('error')) +
+

{{ session('error') }}

+
+ @endif + +
+
+

Your Domains

+

Manage SSL certificates for your domains using Let's Encrypt.

+
+ @if($account->sites->count()) + + + + + + + + + + @foreach($account->sites as $site) + + + + + + @endforeach + +
DomainSSL StatusActions
+
+ @include('components.icons.domain-globe', ['class' => 'h-5 w-5 text-slate-400']) + {{ $site->domain }} +
+
+ @if($site->ssl_enabled) + + + SSL Active + + @else + + + No SSL + + @endif + + @if(!$site->ssl_enabled) +
+ @csrf + +
+ @else + Auto-renews + @endif +
+ @else +
+ @include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300']) +

No domains configured. Add a domain first to enable SSL.

+ + Add Domain + +
+ @endif +
+ +
+

About Let's Encrypt SSL

+
    +
  • + + Free SSL certificates from Let's Encrypt +
  • +
  • + + Certificates auto-renew every 90 days +
  • +
  • + + Your domain must point to this server before issuing SSL +
  • +
+
+
+
diff --git a/resources/views/hosting/panel/terminal.blade.php b/resources/views/hosting/panel/terminal.blade.php new file mode 100644 index 0000000..6103217 --- /dev/null +++ b/resources/views/hosting/panel/terminal.blade.php @@ -0,0 +1,107 @@ + + Terminal - {{ $account->username }} + Terminal + +
+ {{-- Terminal Header --}} +
+
+ {{-- macOS-style window controls --}} +
+ +
+
+
+ + {{-- Session info --}} +
+ {{ $account->username }}@ladill + : + ~ +
+
+ +
+ {{-- Connection status --}} +
+ + +
+ + {{-- Reconnect button --}} + +
+
+ + {{-- Terminal Body --}} +
+
+ + {{-- Connection overlay --}} +
+
+
+ + + + +
+

+
+
+
+ + {{-- Terminal Footer --}} +
+
+ PTY + UTF-8 + bash +
+
+ Ctrl+C + cancel + | + Tab + autocomplete +
+
+
+
diff --git a/resources/views/hosting/partials/order-form-fields.blade.php b/resources/views/hosting/partials/order-form-fields.blade.php new file mode 100644 index 0000000..31117bb --- /dev/null +++ b/resources/views/hosting/partials/order-form-fields.blade.php @@ -0,0 +1,183 @@ +@php + $osLabels = [ + 'alma_9' => 'AlmaLinux 9', 'alma_8' => 'AlmaLinux 8', + 'rocky_9' => 'Rocky Linux 9', 'rocky_8' => 'Rocky Linux 8', + 'ubuntu_22' => 'Ubuntu 22.04', 'ubuntu_20' => 'Ubuntu 20.04', + 'ubuntu_18' => 'Ubuntu 18.04', 'ubuntu_16' => 'Ubuntu 16.04', + 'centos_8' => 'CentOS 8', 'centos_7' => 'CentOS 7', + 'debian_10' => 'Debian 10', 'debian_9' => 'Debian 9', + ]; + $addonLabels = [ + 'cpanel' => 'cPanel', + 'plesk_10_domain' => 'Plesk 10 Domains', + 'plesk_30_domain' => 'Plesk 30 Domains', + 'plesk_unlimited_domain' => 'Plesk Unlimited Domains', + 'plesk_web_admin_license' => 'Plesk Web Admin', + 'plesk_web_pro_license' => 'Plesk Web Pro', + 'plesk_web_host_license' => 'Plesk Web Host', + 'ipaddress' => 'Dedicated IP Address', + 'whmcs' => 'WHMCS License', + 'storage_1' => 'Extra Storage Level 1', + 'storage_2' => 'Extra Storage Level 2', + 'storage_3' => 'Extra Storage Level 3', + 'storage_4' => 'Extra Storage Level 4', + 'storage_5' => 'Extra Storage Level 5', + ]; + $hasAdvanced = $nativeForm['supports_os'] || $nativeForm['supports_addons'] || $nativeForm['supports_ssl_toggle'] || $nativeForm['supports_dedicated_ip']; +@endphp + +
+ @if (session('error')) +
+ {{ session('error') }} +
+ @endif + + @if ($nativeForm['requires_domain']) + @php + $newDomainUrl = ladill_domains_url('/find'); + @endphp +
+
+ +
+ + +
+
+ +
+ @if ($userDomains->isNotEmpty()) +
+ +

+ Prefer another? + Get a new domain +

+
+ @else + + @endif +
+
+ +
+ @error('domain_name') +

{{ $message }}

+ @enderror +
+ @endif + +
+ + + @error('plan_id') +

{{ $message }}

+ @enderror + + +
+ +
+
+ + + @error('months') +

{{ $message }}

+ @enderror +
+
+

Price

+

+ + / mo +

+

+ total +

+
+
+ + @if ($hasAdvanced) +
+ + +
+
+ @if ($nativeForm['supports_os']) +
+ + +
+ @endif + + @if ($nativeForm['supports_addons']) +
+

Add-ons

+
+ @foreach ($nativeForm['addon_options'] as $addon) + + @endforeach +
+
+ @endif + + @if ($nativeForm['supports_ssl_toggle'] || $nativeForm['supports_dedicated_ip']) +
+ @if ($nativeForm['supports_ssl_toggle']) + + @endif + @if ($nativeForm['supports_dedicated_ip']) + + @endif +
+ @endif +
+
+
+ @endif +
diff --git a/resources/views/hosting/partials/server-order-modal.blade.php b/resources/views/hosting/partials/server-order-modal.blade.php new file mode 100644 index 0000000..93f139a --- /dev/null +++ b/resources/views/hosting/partials/server-order-modal.blade.php @@ -0,0 +1,448 @@ + diff --git a/resources/views/hosting/select-type.blade.php b/resources/views/hosting/select-type.blade.php new file mode 100644 index 0000000..64255e9 --- /dev/null +++ b/resources/views/hosting/select-type.blade.php @@ -0,0 +1,64 @@ + + {{ $title }} + + + diff --git a/resources/views/hosting/server-order.blade.php b/resources/views/hosting/server-order.blade.php new file mode 100644 index 0000000..119f4f9 --- /dev/null +++ b/resources/views/hosting/server-order.blade.php @@ -0,0 +1,304 @@ + + {{ $order->domain_name }} - Server Details + + @php + $statusColors = [ + 'pending_payment' => 'bg-amber-50 text-amber-700 border-amber-200', + 'pending_approval' => 'bg-amber-50 text-amber-700 border-amber-200', + 'approved' => 'bg-blue-50 text-blue-700 border-blue-200', + 'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200', + 'active' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'suspended' => 'bg-red-50 text-red-700 border-red-200', + 'cancelled' => 'bg-gray-100 text-gray-500 border-gray-200', + 'expired' => 'bg-gray-100 text-gray-500 border-gray-200', + 'failed' => 'bg-red-50 text-red-700 border-red-200', + ]; + + $statusLabels = collect([ + 'pending_payment', 'pending_approval', 'approved', 'provisioning', + 'active', 'suspended', 'cancelled', 'expired', 'failed', + ])->mapWithKeys(fn (string $status) => [ + $status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status), + ])->all(); + $statusColors['approved'] = 'bg-amber-50 text-amber-700 border-amber-200'; + + $backRoute = $order->product?->type === 'dedicated' ? route('hosting.dedicated') : route('hosting.vps'); + $backLabel = $order->product?->type === 'dedicated' ? 'Dedicated Servers' : 'VPS'; + $serverOrder = (array) ($order->meta['server_order'] ?? []); + $selectionSummary = (array) ($serverOrder['selection_summary'] ?? []); + $quote = (array) ($serverOrder['quote'] ?? []); + $panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($serverOrder, 'panel_snapshot', [])); + $quoteItems = collect((array) ($quote['line_items'] ?? [])) + ->filter(fn ($item) => ($item['code'] ?? null) !== 'base_plan') + ->values(); + @endphp + +
+ {{-- Page Header --}} +
+
+ + + Back to {{ $backLabel }} + +

{{ $order->domain_name }}

+

{{ $order->product?->name ?? 'Server Order' }}

+
+
+ + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + +
+
+ +
+ {{-- Main Content --}} +
+ {{-- Server Details --}} +
+
+

Server Details

+
+
+
+ Server Name + {{ $order->domain_name }} +
+
+ Plan + {{ $order->product?->name ?? '-' }} +
+
+ Region + {{ $order->meta['region'] ?? 'EU' }} +
+ @if ($order->server_ip) +
+ IP Address + {{ $order->server_ip }} +
+ @endif +
+ Billing Cycle + {{ str($order->billing_cycle)->replace('_', ' ')->title() }} +
+
+ Amount + {{ strtoupper($order->currency) }} {{ number_format($order->amount_paid, 2) }} +
+
+
+ + @if ($panelRuntime !== []) +
+
+

Panel Mode

+
+
+
+

{{ $panelRuntime['label'] ?? 'Server Access' }}

+

{{ $panelRuntime['primary_message'] ?? '' }}

+ @if (! empty($panelRuntime['secondary_message'])) +

{{ $panelRuntime['secondary_message'] }}

+ @endif +
+ @if (! empty($panelRuntime['future_capabilities'])) +
+ Managed features after provisioning: {{ implode(', ', (array) $panelRuntime['future_capabilities']) }} +
+ @endif +
+
+ @endif + + {{-- Server Resources --}} + @if ($order->product) +
+
+

Server Resources

+
+
+
+

{{ $order->product->cpu_cores }}

+

vCPU Cores

+
+
+

{{ $order->product->formatRam() }}

+

RAM

+
+
+

{{ $order->product->formatDiskSize() }}

+

SSD Storage

+
+
+

{{ $order->product->formatBandwidth() }}

+

Bandwidth

+
+
+
+ @endif + + @if ($selectionSummary !== []) +
+
+

Selected Options

+
+
+ @foreach ($selectionSummary as $item) +
+
+

{{ $item['label'] ?? 'Option' }}

+ @if (!empty($item['description'])) +

{{ $item['description'] }}

+ @endif +
+
+

{{ $item['value'] ?? '-' }}

+ @if (!empty($item['monthly_amount'])) +

+ {{ strtoupper($order->currency) }} {{ number_format((float) $item['monthly_amount'], 2) }}/mo before term scaling

+ @endif +
+
+ @endforeach +
+
+ @endif + + @if ($quoteItems->isNotEmpty()) +
+
+

Pricing Breakdown

+
+
+ @foreach ($quoteItems as $item) +
+

{{ $item['label'] ?? 'Add-on' }}

+ {{ strtoupper($order->currency) }} {{ number_format((float) ($item['amount'] ?? 0), 2) }} +
+ @endforeach +
+ Total Charged + {{ strtoupper($order->currency) }} {{ number_format((float) $order->amount_paid, 2) }} +
+
+
+ @endif + + {{-- SSH Access (if active) --}} + @if ($order->isActive() && $order->server_ip) +
+
+

SSH Access

+
+
+
+ ssh root@{{ $order->server_ip }} +
+

Use the credentials sent to your email to connect.

+
+
+ @endif +
+ + {{-- Sidebar --}} +
+ {{-- Quick Actions --}} +
+
+

Quick Actions

+
+
+ @if ($order->control_panel_url && $order->isActive()) + + + Open Server Manager + + @endif + + + Get Support + + @if ($order->canBeCancelled()) + + @endif +
+
+ + {{-- Status Timeline --}} +
+
+

Status

+
+
+
+
+
+ +
+
+

Order Placed

+

{{ $order->created_at->format('M d, Y H:i') }}

+
+
+
+
+ @if ($order->approved_at) + + @else +
+ @endif +
+
+

Approved

+ @if ($order->approved_at) +

{{ $order->approved_at->format('M d, Y H:i') }}

+ @endif +
+
+
+
+ @if ($order->provisioned_at) + + @else +
+ @endif +
+
+

Provisioned

+ @if ($order->provisioned_at) +

{{ $order->provisioned_at->format('M d, Y H:i') }}

+ @endif +
+
+
+
+
+
+
+ + {{-- Cancel Modal --}} + +
+
diff --git a/resources/views/hosting/server-panel/show.blade.php b/resources/views/hosting/server-panel/show.blade.php new file mode 100644 index 0000000..e5567d7 --- /dev/null +++ b/resources/views/hosting/server-panel/show.blade.php @@ -0,0 +1,498 @@ + + {{ $order->domain_name }} - Ladill Server Manager + + @php + $powerStatus = strtolower((string) ($panelState['power_status'] ?? 'unknown')); + $instanceStatus = strtolower((string) ($panelState['instance_status'] ?? 'unknown')); + $badgeClasses = [ + 'on' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'off' => 'bg-gray-100 text-gray-600 border-gray-200', + 'running' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'stopped' => 'bg-gray-100 text-gray-600 border-gray-200', + 'provisioning' => 'bg-blue-50 text-blue-700 border-blue-200', + 'unknown' => 'bg-amber-50 text-amber-700 border-amber-200', + 'online' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'offline' => 'bg-gray-100 text-gray-600 border-gray-200', + 'pending_registration' => 'bg-amber-50 text-amber-700 border-amber-200', + 'queued' => 'bg-slate-100 text-slate-700 border-slate-200', + 'dispatched' => 'bg-blue-50 text-blue-700 border-blue-200', + 'completed' => 'bg-emerald-50 text-emerald-700 border-emerald-200', + 'failed' => 'bg-rose-50 text-rose-700 border-rose-200', + ]; + $panelRuntime = $panelRuntime ?? []; + $agentStatus = strtolower((string) ($serverAgent?->status ?? 'pending_registration')); + $managedTasksEnabled = (bool) ($panelRuntime['managed_stack_candidate'] ?? false); + $taskTypeLabels = [ + 'domain.add' => 'Add Domain', + 'site.delete' => 'Remove Site', + 'ssl.request' => 'Request SSL', + 'database.create' => 'Create Database', + 'php.configure' => 'Configure PHP', + 'cron.list' => 'List Cron Jobs', + 'cron.upsert' => 'Save Cron Job', + 'cron.remove' => 'Remove Cron Job', + 'log.read' => 'Read Logs', + 'file.list' => 'List Files', + 'file.read' => 'Read File', + 'file.write' => 'Write File', + ]; + $cronEntries = (array) data_get($order->meta, 'server_panel.managed_cron_entries', []); + $logSnapshots = collect((array) data_get($order->meta, 'server_panel.log_snapshots', [])) + ->sortByDesc('read_at') + ->values(); + @endphp + +
+
+
+ + + Back to server details + +

Ladill Server Manager

+

{{ $order->domain_name }} · {{ $order->product?->name ?? 'Server' }}

+
+
+
+ @csrf + +
+
+
+ +
+
+

Power

+ + {{ strtoupper($powerStatus) }} + +
+
+

Instance Status

+ + {{ strtoupper($instanceStatus) }} + +
+
+

IPv4 Address

+

{{ $order->server_ip ?: '-' }}

+
+
+

Last Sync

+

+ {{ data_get($panelState, 'last_synced_at') ? \Illuminate\Support\Carbon::parse(data_get($panelState, 'last_synced_at'))->format('M d, Y H:i') : 'Not synced yet' }} +

+
+
+ +
+
+ @if ($panelRuntime !== []) +
+
+

Panel Mode

+
+
+
+

{{ $panelRuntime['label'] ?? 'Server Manager' }}

+

{{ $panelRuntime['primary_message'] ?? '' }}

+ @if (!empty($panelRuntime['secondary_message'])) +

{{ $panelRuntime['secondary_message'] }}

+ @endif +
+ @if (!empty($panelRuntime['future_capabilities'])) +
+ Managed features: {{ implode(', ', (array) $panelRuntime['future_capabilities']) }} +
+ @endif +
+
+ @endif + +
+
+

Server Controls

+
+
+
+ @csrf + +
+
+ @csrf + +
+
+ @csrf + +
+
+
+ +
+
+

Selected Configuration

+
+
+ @foreach ($selectionSummary as $item) +
+
+

{{ $item['label'] ?? 'Option' }}

+ @if (!empty($item['description'])) +

{{ $item['description'] }}

+ @endif +
+

{{ $item['value'] ?? '-' }}

+
+ @endforeach +
+
+ + @if ($managedTasksEnabled) +
+
+

Managed Tasks

+
+
+
+
+ @csrf +
+

Queue Domain Link

+

Create or update a site/vhost on the managed server stack.

+
+ + + + +
+ +
+ @csrf +
+

Queue Database Create

+

The server agent will provision the database on the managed stack.

+
+ + +
+ +
+ @csrf +
+

Queue SSL Request

+

Issue or renew a certificate for a managed server domain.

+
+ + +
+ +
+ @csrf +
+

Queue File Task

+

Use this for file listing, reads, or writes through the server agent.

+
+ + + + +
+ +
+ @csrf +
+

Queue PHP Version Change

+

Switch the nginx vhost for a known site to a different PHP-FPM version.

+
+ + + +
+ +
+ @csrf +
+

Queue Site Removal

+

Remove the nginx site configuration for a managed domain. Website files are left on disk.

+
+ + +
+ +
+ @csrf +
+

Queue Cron Task

+

Manage the Ladill-owned cron file on this server. Use a standard 5-field cron expression.

+
+ + + + + + +
+ +
+ @csrf +
+

Queue Log Read

+

Tail domain-specific nginx logs or service journals directly from the managed server.

+
+ + + + +
+
+ +
+
+

Recent Tasks

+
+
+ @forelse ($serverTasks as $task) +
+
+

{{ $taskTypeLabels[$task->type] ?? $task->type }}

+

{{ json_encode($task->payload ?? [], JSON_UNESCAPED_SLASHES) }}

+ @if ($task->error_message) +

{{ $task->error_message }}

+ @elseif (($task->result['message'] ?? null)) +

{{ $task->result['message'] }}

+ @endif +
+
+ + {{ strtoupper((string) $task->status) }} + +

{{ $task->updated_at?->format('M d, Y H:i') }}

+
+
+ @empty +
No managed tasks queued yet.
+ @endforelse +
+
+ +
+
+
+

Known Sites

+
+
+ @forelse ($serverSites as $site) +
+

{{ $site->domain }}

+

{{ $site->document_root ?: 'Document root pending' }}

+

+ PHP {{ $site->php_version ?: 'default' }} · SSL {{ strtoupper((string) ($site->ssl_status ?: 'pending')) }} +

+

+ {{ strtoupper((string) $site->status) }} +

+
+ @empty +
No managed sites recorded yet.
+ @endforelse +
+
+ +
+
+

Known Databases

+
+
+ @forelse ($serverDatabases as $database) +
+

{{ $database->name }}

+

{{ $database->username ?: 'Username pending' }} · {{ strtoupper((string) $database->status) }}

+

{{ strtoupper((string) $database->engine) }}

+
+ @empty +
No managed databases recorded yet.
+ @endforelse +
+
+ +
+
+

File Sync Records

+
+
+ @forelse ($serverFiles as $file) +
+

{{ $file->path }}

+

{{ ucfirst($file->kind) }} · {{ $file->size_bytes !== null ? number_format($file->size_bytes) . ' bytes' : 'Size pending' }}

+

{{ $file->last_synced_at?->format('M d, Y H:i') ?: 'Not synced yet' }}

+
+ @empty +
No managed file records yet.
+ @endforelse +
+
+
+ +
+
+
+

Managed Cron Entries

+
+
+ @forelse ($cronEntries as $entry) +
+

{{ $entry['name'] ?? 'cron-job' }}

+

{{ $entry['schedule'] ?? '-' }} · {{ $entry['user'] ?? 'web' }}

+

{{ $entry['command'] ?? '-' }}

+
+ @empty +
No managed cron entries synced yet.
+ @endforelse +
+
+ +
+
+

Recent Log Snapshots

+
+
+ @forelse ($logSnapshots as $snapshot) +
+

+ {{ strtoupper(str_replace('_', ' ', (string) ($snapshot['target'] ?? 'log'))) }} + @if (!empty($snapshot['domain'])) + · {{ $snapshot['domain'] }} + @endif +

+

{{ $snapshot['read_at'] ? \Illuminate\Support\Carbon::parse($snapshot['read_at'])->format('M d, Y H:i') : 'Pending' }}

+
{{ $snapshot['content_preview'] ?? 'No preview available yet.' }}
+
+ @empty +
No log snapshots captured yet.
+ @endforelse +
+
+
+
+
+ @endif +
+ +
+ @if ($managedTasksEnabled) +
+
+

Agent Status

+
+
+
+ Status + + {{ strtoupper($agentStatus) }} + +
+
+ Hostname + {{ $serverAgent?->hostname ?: 'Not registered yet' }} +
+
+ Version + {{ $serverAgent?->agent_version ?: '-' }} +
+
+ Last Heartbeat + + {{ $serverAgent?->last_heartbeat_at ? $serverAgent->last_heartbeat_at->format('M d, Y H:i') : 'Waiting for registration' }} + +
+ @if (! empty($serverAgent?->capabilities)) +
+

Reported Capabilities

+

{{ implode(', ', (array) $serverAgent->capabilities) }}

+
+ @endif +
+ The agent registration and heartbeat pipeline is now active. Task execution still depends on the server-side agent binary being installed on the machine. +
+
+
+ @endif + +
+
+

Instance Details

+
+
+
+ Region + {{ data_get($panelState, 'region', $order->meta['region'] ?? '-') }} +
+
+ Datacenter + {{ data_get($panelState, 'datacenter', '-') }} +
+
+ Image + {{ data_get($panelState, 'image', '-') }} +
+
+ Username + {{ $order->server_username ?: 'root' }} +
+
+
+ +
+

Access

+
+

Use the server password you set at checkout to access the machine.

+ @if ($order->server_ip) +
+ ssh {{ $order->server_username ?: 'root' }}@{{ $order->server_ip }} +
+ @endif +
+
+
+
+
+
diff --git a/resources/views/hosting/server-type.blade.php b/resources/views/hosting/server-type.blade.php new file mode 100644 index 0000000..14706fb --- /dev/null +++ b/resources/views/hosting/server-type.blade.php @@ -0,0 +1,225 @@ + + {{ $title }} + + @php + $errors = $errors ?? new \Illuminate\Support\ViewErrorBag; + $orderFormErrorFields = [ + 'product_id', + 'server_name', + 'billing_cycle', + 'region', + 'image', + 'license', + 'application', + 'default_user', + 'additional_ip', + 'private_networking', + 'storage_type', + 'object_storage', + 'server_password', + 'server_password_confirmation', + ]; + $hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field)); + $shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors; + + $initialServerStep = old('product_id') + ? (($errors->has('server_password') || $errors->has('server_password_confirmation')) ? 3 : 2) + : 1; + + $initialServerOrderState = [ + 'selectedProductId' => old('product_id', ''), + 'serverName' => old('server_name', ''), + 'billingCycle' => old('billing_cycle', \App\Models\CustomerHostingOrder::CYCLE_MONTHLY), + 'step' => $initialServerStep, + 'selections' => [ + 'region' => old('region', ''), + 'image' => old('image', ''), + 'license' => old('license', ''), + 'application' => old('application', ''), + 'default_user' => old('default_user', ''), + 'additional_ip' => old('additional_ip', ''), + 'private_networking' => old('private_networking', ''), + 'storage_type' => old('storage_type', ''), + 'object_storage' => old('object_storage', ''), + ], + ]; + + $statusColors = [ + 'pending_payment' => 'bg-amber-50 text-amber-700', + 'pending_approval' => 'bg-amber-50 text-amber-700', + 'approved' => 'bg-blue-50 text-blue-700', + 'provisioning' => 'bg-blue-50 text-blue-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'suspended' => 'bg-red-50 text-red-700', + 'cancelled' => 'bg-gray-100 text-gray-500', + 'expired' => 'bg-gray-100 text-gray-500', + 'failed' => 'bg-red-50 text-red-700', + ]; + + $statusLabels = collect([ + 'pending_payment', 'pending_approval', 'approved', 'provisioning', + 'active', 'suspended', 'cancelled', 'expired', 'failed', + ])->mapWithKeys(fn (string $status) => [ + $status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status), + ])->all(); + $statusColors['approved'] = 'bg-amber-50 text-amber-700'; + + $formatMoney = static fn (?float $amount, string $currency = 'GHS'): string => $amount === null + ? '-' + : strtoupper($currency).' '.number_format($amount, 2); + + $hasAnyProducts = $orders->isNotEmpty() || $rcServiceOrders->isNotEmpty(); + + $emptyStateIcon = $type === 'vps' + ? '' + : ''; + + $emptyStateTitle = $type === 'vps' ? 'No VPS instances' : 'No dedicated servers'; + $emptyStateDescription = $type === 'vps' + ? 'You don\'t have any VPS instances yet. Deploy a virtual private server to get started with scalable cloud infrastructure.' + : 'You don\'t have any dedicated servers yet. Get a dedicated server for maximum performance and full control over your infrastructure.'; + @endphp + +
+ {{-- Page Header --}} +
+
+

{{ $title }}

+

Manage your {{ strtolower($title) }} orders.

+
+ @if ($serverOrderPayloads !== []) + + @endif +
+ + @if ($hasAnyProducts) +
+

Your Servers

+ + @if ($orders->isNotEmpty()) +
+
+

New Server Orders

+
+ + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + + @endforeach + +
ServerPlanStatusIP AddressActions
+
{{ $order->domain_name }}
+
{{ $order->meta['region'] ?? 'EU' }}
+
+
{{ $order->product?->name ?? '-' }}
+
+ @if ($order->product) + {{ $order->product->cpu_cores }} vCPU · {{ $order->product->formatRam() }} + @endif +
+
+ + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + + + {{ $order->server_ip ?? '-' }} + + + Manage → + +
+
+ @endif + + @if ($rcServiceOrders->isNotEmpty()) +
+
+

Legacy Service Orders

+
+ + + + + + + + + + + + @foreach ($rcServiceOrders as $order) + + + + + + + + @endforeach + +
ServicePlanStatusIP AddressActions
+
{{ $order->domain_name ?: $order->service_name }}
+
+
{{ $order->plan_name }}
+
+ + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + + + {{ $order->ip_address ?? '-' }} + + + Manage → + +
+
+ @endif +
+ @else + {{-- Empty State --}} +
+
+ + {!! $emptyStateIcon !!} + +
+

{{ $emptyStateTitle }}

+

{{ $emptyStateDescription }}

+ @if ($serverOrderPayloads !== []) +
+ +
+ @endif +
+ @endif + + {{-- Order Modal --}} + @if ($serverOrderPayloads !== []) + @include('hosting.partials.server-order-modal') + @endif + +
+
diff --git a/resources/views/hosting/show.blade.php b/resources/views/hosting/show.blade.php new file mode 100644 index 0000000..9b5444e --- /dev/null +++ b/resources/views/hosting/show.blade.php @@ -0,0 +1,424 @@ + + Hosting — {{ $order->domain_name }} + + @php + $statusColors = [ + 'active' => 'bg-emerald-50 text-emerald-700', + 'pending' => 'bg-amber-50 text-amber-700', + 'suspended' => 'bg-red-50 text-red-700', + 'expired' => 'bg-gray-100 text-gray-600', + 'cancelled' => 'bg-gray-100 text-gray-500', + 'failed' => 'bg-red-50 text-red-700', + ]; + $statusBadge = $statusColors[$order->status] ?? 'bg-gray-100 text-gray-600'; + $nameservers = collect((array) $order->nameservers)->filter()->values(); + $renewalOptions = collect($renewalOptions ?? [])->values(); + $initialRenewalMonths = (string) ($renewalOptions->first()['months'] ?? '12'); + $backRoute = match ((string) $order->hosting_type) { + 'multi_domain' => route('hosting.multi-domain'), + 'wordpress' => route('hosting.wordpress'), + default => route('hosting.single-domain'), + }; + @endphp + +
+ {{-- Page Header --}} +
+ + + Hosting + +
+
+
+

{{ $order->domain_name }}

+ {{ ucfirst($order->status) }} +
+

+ @if ($order->rc_order_id) + Order #{{ $order->rc_order_id }} + @else + Local order #{{ $order->id }} + @endif + @if ($order->plan_name) · {{ $order->plan_name }} @endif +

+
+
+ @if ($order->cpanel_url) + + + Open cPanel + + @endif + @if ($order->rc_order_id) + + @endif +
+
+
+ + {{-- Quick Stats --}} +
+
+

Status

+

{{ ucfirst($order->status) }}

+
+
+

IP Address

+

{{ $order->ip_address ?: '—' }}

+
+
+

Plan

+

{{ $order->plan_name ?: '—' }}

+
+
+

Expires

+

{{ $order->expires_at ? $order->expires_at->format('M d, Y') : '—' }}

+
+
+ + {{-- Details + Renew --}} +
+ {{-- Details Card --}} +
+

Hosting Details

+
+
+
Domain
+
{{ $order->domain_name }}
+
+
+
Hosting Type
+
{{ str_replace('_', ' ', ucfirst($order->hosting_type)) }}
+
+
+
cPanel Username
+
{{ $order->cpanel_username ?: '—' }}
+
+
+
Access Status
+
{{ $order->cpanel_url ? 'Ready' : 'Waiting for provisioning' }}
+
+ @if ($order->website_url) +
+
Website URL
+
{{ $order->website_url }}
+
+ @endif + @if ($order->direct_url) +
+
Direct URL
+
{{ $order->direct_url }}
+
+ @endif +
+
Disk Space
+
{{ $order->disk_space_mb ? number_format($order->disk_space_mb) . ' MB' : '—' }}
+
+
+
Bandwidth
+
{{ $order->bandwidth_mb ? number_format($order->bandwidth_mb) . ' MB' : '—' }}
+
+
+
Provisioned At
+
{{ $order->provisioned_at ? $order->provisioned_at->format('M d, Y H:i') : '—' }}
+
+
+
Last Updated
+
{{ $order->updated_at->format('M d, Y H:i') }}
+
+ @if ($order->cpanel_url) +
+
Console URL
+
{{ $order->cpanel_url }}
+
+ @endif +
+
+ + {{-- Sidebar --}} +
+ {{-- Control Panel Access --}} +
+
+
+ +
+
+

Control Panel

+

{{ $order->cpanel_url ? 'Ready to access' : 'Waiting for provisioning' }}

+
+
+
+
+ Username + {{ $order->cpanel_username ?: '—' }} +
+
+ Panel URL + {{ $order->cpanel_url ? parse_url($order->cpanel_url, PHP_URL_HOST) : 'Pending' }} +
+ @if ($order->direct_url) +
+ Direct URL + {{ parse_url($order->direct_url, PHP_URL_HOST) }} +
+ @endif +
+ @if ($order->cpanel_url) + + @endif +
+ + {{-- Nameservers --}} +
+
+
+ @include('components.icons.domain-globe', ['class' => 'h-5 w-5']) +
+
+

Nameservers

+

Point your domain to this hosting

+
+
+ @if ($nameservers->isNotEmpty()) +
+ @foreach ($nameservers as $index => $nameserver) +
+ NS{{ $index + 1 }} + {{ $nameserver }} +
+ @endforeach +
+ @else +
+

Nameservers will appear once provisioning is complete.

+
+ @endif +
+ + {{-- Server Details --}} + @if ($order->ip_address || $order->website_url) +
+
+
+ +
+
+

Server Details

+

For DNS and manual configuration

+
+
+
+ @if ($order->ip_address) +
+ Server IP + {{ $order->ip_address }} +
+ @endif + @if ($order->website_url) +
+ Primary Domain + {{ $order->website_url }} +
+ @endif +
+
+ @endif + + {{-- Renew Card --}} + @if (($order->isActive() || ($order->expires_at && $order->expires_at->diffInDays(now()) < 30)) && $renewalOptions->isNotEmpty()) +
+

Renew Hosting

+

Extend your hosting before it expires.

+ +
+
+ + +
+ @if ($renewalOptions->contains(fn (array $option) => ! empty($option['monthly']))) +

+ Renewal options are based on the current pricing for this plan. +

+ @endif + +
+ + + +
+ @endif + +
+
+ + {{-- Password Modal --}} + +
+
diff --git a/resources/views/hosting/signed-out.blade.php b/resources/views/hosting/signed-out.blade.php new file mode 100644 index 0000000..ba3ceee --- /dev/null +++ b/resources/views/hosting/signed-out.blade.php @@ -0,0 +1,17 @@ + + + + + Signed out — Ladill Hosting + @include('partials.favicon') + @vite(['resources/css/app.css']) + + + +
+ Ladill Hosting +

You’ve been signed out. Redirecting…

+ Go to ladill.com +
+ + diff --git a/resources/views/hosting/type.blade.php b/resources/views/hosting/type.blade.php new file mode 100644 index 0000000..4766915 --- /dev/null +++ b/resources/views/hosting/type.blade.php @@ -0,0 +1,381 @@ + + {{ $title }} + + @php + $errors = $errors ?? new \Illuminate\Support\ViewErrorBag; + $orderFormErrorFields = ['domain_name', 'plan_id', 'months']; + $hasOrderFormErrors = collect($orderFormErrorFields)->contains(fn ($field) => $errors->has($field)); + $shouldOpenOrderModal = (bool) session('open_product_order_modal', false) || $hasOrderFormErrors; + $knownDomainHosts = $userDomains->pluck('host')->all(); + $oldDomainName = trim((string) old('domain_name', '')); + $initialDomainSource = $oldDomainName !== '' && ! in_array($oldDomainName, $knownDomainHosts, true) ? 'external' : 'ladill'; + $initialExternalDomain = $initialDomainSource === 'external' ? $oldDomainName : ''; + $productOrderFormConfig = [ + 'packages' => $nativeForm['packages'] ?? [], + 'selectedPlan' => old('plan_id', (string) request('plan_id', $nativeForm['packages'][0]['value'] ?? '')), + 'selectedMonths' => (string) old('months', ''), + ]; + + $statusColors = [ + 'pending_payment' => 'bg-amber-50 text-amber-700', + 'pending_approval' => 'bg-amber-50 text-amber-700', + 'approved' => 'bg-blue-50 text-blue-700', + 'provisioning' => 'bg-blue-50 text-blue-700', + 'active' => 'bg-emerald-50 text-emerald-700', + 'suspended' => 'bg-red-50 text-red-700', + 'cancelled' => 'bg-gray-100 text-gray-500', + 'expired' => 'bg-gray-100 text-gray-500', + 'failed' => 'bg-red-50 text-red-700', + ]; + + $statusLabels = collect([ + 'pending_payment', 'pending_approval', 'approved', 'provisioning', + 'active', 'suspended', 'cancelled', 'expired', 'failed', + ])->mapWithKeys(fn (string $status) => [ + $status => \App\Models\CustomerHostingOrder::customerFacingStatusLabelFor($status), + ])->all(); + $statusColors['approved'] = 'bg-amber-50 text-amber-700'; + + $accountStatusColors = [ + 'active' => 'bg-emerald-50 text-emerald-700', + 'suspended' => 'bg-red-50 text-red-700', + 'cancelled' => 'bg-gray-100 text-gray-500', + ]; + + $formatMoney = static fn (?float $amount, string $currency = 'GHS'): string => $amount === null + ? '-' + : strtoupper($currency).' '.number_format($amount, 2); + + $hasAnyProducts = $orders->isNotEmpty() + || $hostingAccounts->isNotEmpty() + || $rcHostingOrders->isNotEmpty() + || $rcServiceOrders->isNotEmpty(); + @endphp + +
+ {{-- Page Header --}} +
+
+
+ @if ($type === \App\Models\HostingProduct::TYPE_WORDPRESS) + @include('partials.wordpress-icon', ['class' => 'h-9 w-9 object-contain']) + @endif +

{{ $title }}

+
+

Manage your {{ strtolower($title) }} products and orders.

+
+ @if ($nativeForm && ($nativeForm['has_packages'] ?? false)) + + @endif +
+ + {{-- Products List --}} + @if ($hasAnyProducts) +
+ + {{-- Admin-Assigned Hosting Accounts --}} + @if ($hostingAccounts->isNotEmpty()) +
+
+

Active Hosting Accounts

+

Your provisioned hosting accounts and linked domains.

+
+
+ + + + + + + + + + + + + @foreach ($hostingAccounts as $account) + @php + $sharedDeveloperAccess = (int) $account->user_id !== (int) auth()->id(); + $accountActionUrl = $sharedDeveloperAccess + ? route('hosting.panel.index', $account) + : route('hosting.accounts.show', $account); + @endphp + + + + + + + + + @endforeach + +
AccountPlanDomainStatusExpires
+
+
+ + + +
+
+

{{ $account->username ?: '—' }}

+

Hosting account

+
+
+
+

{{ $account->product?->name ?? 'Hosting Account' }}

+ @if ($sharedDeveloperAccess) +

Developer access

+ @endif +
{{ $account->linkedDomainLabel() ?? '—' }} + + {{ ucfirst($account->status) }} + + {{ $account->expires_at?->format('M j, Y') ?? '—' }} + + {{ $sharedDeveloperAccess ? 'Open Panel' : 'Manage' }} + + + + +
+
+
+ @endif + + {{-- New Hosting Orders --}} + @if ($orders->isNotEmpty()) +
+
+

New Hosting Orders

+

Recent orders still being provisioned or awaiting action.

+
+
+ + + + + + + + + + + + @foreach ($orders as $order) + + + + + + + + @endforeach + +
DomainPlanStatusExpires
+
+
+ + + +
+
+

{{ filled($order->domain_name) ? $order->domain_name : '—' }}

+ @if ($order->server_ip) +

{{ $order->server_ip }}

+ @else +

{{ ucfirst($order->billing_cycle) }}

+ @endif +
+
+
+

{{ $order->product?->name ?? '—' }}

+

{{ ucfirst($order->billing_cycle) }}

+
+ + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + + {{ $order->expires_at?->format('M j, Y') ?? '—' }} + + Manage + + + + +
+
+
+ @endif + + {{-- RC Hosting Orders (Legacy) --}} + @if ($rcHostingOrders->isNotEmpty()) +
+
+

Legacy Hosting Orders

+

Hosting orders from the previous ordering system.

+
+
+ + + + + + + + + + + + @foreach ($rcHostingOrders as $order) + + + + + + + + @endforeach + +
DomainPlanStatusExpires
{{ filled($order->domain_name) ? $order->domain_name : '—' }}{{ $order->plan_name }} + + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + + {{ $order->expires_at?->format('M j, Y') ?? '—' }} + + Manage + + + + +
+
+
+ @endif + + {{-- RC Service Orders (Legacy) --}} + @if ($rcServiceOrders->isNotEmpty()) +
+
+

Legacy Service Orders

+

Older service records carried forward for reference and management.

+
+
+ + + + + + + + + + + + @foreach ($rcServiceOrders as $order) + + + + + + + + @endforeach + +
ServicePlanStatusExpires
{{ $order->domain_name ?: $order->service_name }}{{ $order->plan_name }} + + {{ $statusLabels[$order->status] ?? ucfirst($order->status) }} + + {{ $order->expires_at?->format('M j, Y') ?? '—' }} + + Manage + + + + +
+
+
+ @endif +
+ @endif + + {{-- Empty State - No Products --}} + @if (! $hasAnyProducts) +
+
+ + + +
+

No {{ strtolower($title) }} products

+

You don't have any {{ strtolower($title) }} products yet. Get started with reliable web hosting for your websites.

+ @if ($nativeForm && ($nativeForm['has_packages'] ?? false)) +
+ +
+ @endif +
+ @endif + + {{-- Order Modal --}} + @if ($nativeForm && ($nativeForm['has_packages'] ?? false)) + + @endif + +
+
diff --git a/resources/views/layouts/email.blade.php b/resources/views/layouts/email.blade.php new file mode 100644 index 0000000..9ecf02b --- /dev/null +++ b/resources/views/layouts/email.blade.php @@ -0,0 +1,60 @@ + + + + + + + @yield('title', 'Ladill Email') + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + @include('partials.mailbox-link-banner') + @yield('content') +
+
+
+ @auth + @php + $navUser = auth()->user(); + $navInitials = collect(explode(' ', trim((string) $navUser?->name))) + ->filter()->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); + $navAcct = 'https://'.config('app.account_domain'); + @endphp + @include('partials.mobile-bottom-nav', [ + 'homeUrl' => route('email.dashboard'), + 'homeActive' => request()->routeIs('email.dashboard'), + 'searchUrl' => route('email.mailboxes.index'), + 'searchActive' => request()->routeIs('email.mailboxes.*'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), + 'profileActive' => false, + 'profileName' => $navUser?->name ?? '', + 'profileSubtitle' => $navUser?->email ?? '', + 'profileMenuItems' => [ + ['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'], + ['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'], + ['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')], + ], + 'avatarUrl' => $navUser?->avatar_url, + 'initials' => $navInitials !== '' ? $navInitials : 'U', + ]) + @include('partials.afia') + @endauth + + diff --git a/resources/views/layouts/hosting.blade.php b/resources/views/layouts/hosting.blade.php new file mode 100644 index 0000000..0b9e88f --- /dev/null +++ b/resources/views/layouts/hosting.blade.php @@ -0,0 +1,59 @@ + + + + + + + @yield('title', 'Ladill Servers') + @include('partials.favicon') + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ +
+ @include('partials.topbar') +
+ @include('partials.flash') + @yield('content') +
+
+
+ @auth + @php + $navUser = auth()->user(); + $navInitials = collect(explode(' ', trim((string) $navUser?->name))) + ->filter()->take(2) + ->map(fn ($part) => strtoupper(substr($part, 0, 1))) + ->implode(''); + $navAcct = 'https://'.config('app.account_domain'); + @endphp + @include('partials.mobile-bottom-nav', [ + 'homeUrl' => route('servers.dashboard'), + 'homeActive' => request()->routeIs('servers.dashboard') || request()->routeIs('hosting.index'), + 'searchUrl' => route('servers.dashboard'), + 'searchActive' => request()->routeIs('hosting.*'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), + 'profileActive' => false, + 'profileName' => $navUser?->name ?? '', + 'profileSubtitle' => $navUser?->email ?? '', + 'profileMenuItems' => [ + ['type' => 'link', 'label' => 'Profile', 'href' => $navAcct.'/profile'], + ['type' => 'link', 'label' => 'Account Settings', 'href' => $navAcct.'/account-settings'], + ['type' => 'logout', 'label' => 'Logout', 'action' => route('logout')], + ], + 'avatarUrl' => $navUser?->avatar_url, + 'initials' => $navInitials !== '' ? $navInitials : 'U', + ]) + @include('partials.afia') + @endauth + + diff --git a/resources/views/notifications/_list.blade.php b/resources/views/notifications/_list.blade.php new file mode 100644 index 0000000..09c6ebb --- /dev/null +++ b/resources/views/notifications/_list.blade.php @@ -0,0 +1,93 @@ +
+
+
+

Notifications

+

{{ $subtitle ?? 'Stay updated on your account activity.' }}

+
+ @if ($notifications->where('read_at', null)->count() > 0) +
+ @csrf + +
+ @endif +
+ +
+ @forelse ($notifications as $notification) + @php + $data = $notification->data; + $icon = $data['icon'] ?? 'bell'; + $iconBg = match ($icon) { + 'domain' => 'bg-emerald-50', + 'hosting' => 'bg-violet-50', + 'email' => 'bg-pink-50', + 'billing' => 'bg-amber-50', + 'success' => 'bg-green-50', + default => 'bg-slate-100', + }; + $iconColor = match ($icon) { + 'domain' => 'text-emerald-600', + 'hosting' => 'text-violet-600', + 'email' => 'text-pink-600', + 'billing' => 'text-amber-600', + 'success' => 'text-green-600', + default => 'text-slate-500', + }; + @endphp +
+ + @if ($icon === 'domain' && view()->exists('components.icons.domain-globe')) + @include('components.icons.domain-globe', ['class' => 'h-5 w-5 ' . $iconColor]) + @elseif ($icon === 'email') + + @elseif ($icon === 'hosting') + + @elseif ($icon === 'billing') + + @elseif ($icon === 'success') + + @else + + @endif + +
+
+
+

{{ $data['title'] ?? 'Notification' }}

+

{{ $data['message'] ?? '' }}

+
+ {{ $notification->created_at->diffForHumans() }} +
+ @if (! empty($data['url'])) + + View details → + + @endif +
+ @if (! $notification->read_at) +
+ @csrf + +
+ @endif +
+ @empty +
+ + + +

No notifications

+

{{ $emptyMessage ?? "You're all caught up." }}

+
+ @endforelse +
+ + @if ($notifications->hasPages()) +
{{ $notifications->links() }}
+ @endif +
diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php new file mode 100644 index 0000000..9901476 --- /dev/null +++ b/resources/views/notifications/index.blade.php @@ -0,0 +1,10 @@ +@extends('layouts.hosting') + +@section('title', 'Notifications') + +@section('content') + @include('notifications._list', [ + 'subtitle' => 'Hosting activity for your account.', + 'emptyMessage' => "You're all caught up. We'll notify you when something happens with your hosting.", + ]) +@endsection diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php new file mode 100644 index 0000000..adc2c23 --- /dev/null +++ b/resources/views/partials/afia.blade.php @@ -0,0 +1,102 @@ +@php + $afiaProduct = $afiaProduct ?? (request()->routeIs('email.*') ? 'email' : 'hosting'); + $afiaGreeting = $afiaProduct === 'email' + ? "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…" + : "Hi, I'm Afia 👋 Ask me anything about hosting — choosing a plan, linking a domain, using the control panel, SSL, or renewals…"; + $afiaSuggestions = $afiaProduct === 'email' + ? ['How do I set up email for my domain?', 'What DNS records do I need to verify?', 'How do I create a mailbox?', 'What are my IMAP and SMTP settings?'] + : ['How do I link a domain to my hosting?', 'How do I open the control panel?', 'How do I renew my hosting plan?', 'How do I install WordPress?']; + $afiaSubtitle = $afiaProduct === 'email' ? 'Email assistant' : 'Hosting assistant'; +@endphp +{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}} +
+
+ +
+
+
+ + + + +
+

Afia

+

{{ $afiaSubtitle }}

+
+
+ +
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ +
+
+ +
+
+ + +
+

Afia can make mistakes — verify important details.

+
+
+
diff --git a/resources/views/partials/favicon.blade.php b/resources/views/partials/favicon.blade.php new file mode 100644 index 0000000..db5adb3 --- /dev/null +++ b/resources/views/partials/favicon.blade.php @@ -0,0 +1,3 @@ +@php $faviconVer = @filemtime(public_path('favicon.ico')) ?: '1'; @endphp + + diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php new file mode 100644 index 0000000..8116c8e --- /dev/null +++ b/resources/views/partials/flash.blade.php @@ -0,0 +1,22 @@ +@php + $flashStyles = [ + 'success' => 'border-emerald-200 bg-emerald-50 text-emerald-800', + 'info' => 'border-sky-200 bg-sky-50 text-sky-800', + 'error' => 'border-red-200 bg-red-50 text-red-800', + ]; +@endphp +@foreach ($flashStyles as $key => $classes) + @if (session($key)) +
{{ session($key) }}
+ @endif +@endforeach + +@if ($errors->any()) +
+
    + @foreach ($errors->all() as $err) +
  • {{ $err }}
  • + @endforeach +
+
+@endif diff --git a/resources/views/partials/ladill-pro-icon.blade.php b/resources/views/partials/ladill-pro-icon.blade.php new file mode 100644 index 0000000..4ec9e3f --- /dev/null +++ b/resources/views/partials/ladill-pro-icon.blade.php @@ -0,0 +1,2 @@ +@props(['class' => 'h-5 w-5']) + diff --git a/resources/views/partials/launcher.blade.php b/resources/views/partials/launcher.blade.php new file mode 100644 index 0000000..c26873d --- /dev/null +++ b/resources/views/partials/launcher.blade.php @@ -0,0 +1,53 @@ +@php + // Shared Ladill app launcher — IDENTICAL across every app/service. + // Driven by config/ladill_launcher.php (fully-extracted apps only) + icons + // from public/images/launcher-icons/. Omits the current app (matched by + // APP_URL host). To replicate elsewhere, copy this file + the config + + // the launcher-icons folder verbatim. + $sidebar = (bool) ($sidebar ?? false); + $selfHost = parse_url((string) config('app.url'), PHP_URL_HOST); + $launcherApps = array_values(array_filter( + config('ladill_launcher.apps', []), + fn (array $app) => parse_url($app['url'], PHP_URL_HOST) !== $selfHost + )); +@endphp +@if (! empty($launcherApps)) +
+ +
! $sidebar, + 'bottom-full left-0 mb-2' => $sidebar, + ])> +

All apps

+
+ @foreach ($launcherApps as $app) + + + + + {{ $app['name'] }} + + @endforeach +
+
+
+@endif diff --git a/resources/views/partials/mailbox-link-banner.blade.php b/resources/views/partials/mailbox-link-banner.blade.php new file mode 100644 index 0000000..8e01144 --- /dev/null +++ b/resources/views/partials/mailbox-link-banner.blade.php @@ -0,0 +1,46 @@ +@if(($mailboxLinkReminder['visible'] ?? false) === true) + @php + $stage = $mailboxLinkReminder['stage'] ?? 'needs_link'; + $copy = match ($stage) { + 'needs_domain' => [ + 'title' => 'Set up Ladill Email', + 'body' => 'Add and verify an email domain before you can create a mailbox and link it to your Ladill account.', + 'cta' => 'Add email domain', + 'url' => route('email.domains.index'), + ], + 'needs_mailbox' => [ + 'title' => 'Create your Ladill mailbox', + 'body' => 'Your domain is ready. Create a mailbox, then link it in Settings so Ladill Mail opens with your Ladill sign-in.', + 'cta' => 'Create mailbox', + 'url' => route('email.mailboxes.create'), + ], + default => [ + 'title' => 'Link your Ladill mailbox', + 'body' => 'Your account uses '.$mailboxLinkReminder['account_email'].' — choose a mailbox in Settings to connect Ladill Mail sign-in.', + 'cta' => 'Link mailbox', + 'url' => route('account.settings'), + ], + }; + @endphp +
+
+ +
+

{{ $copy['title'] }}

+

{{ $copy['body'] }}

+ + {{ $copy['cta'] }} + + +
+
+ @csrf + +
+
+
+@endif diff --git a/resources/views/partials/mobile-bottom-nav.blade.php b/resources/views/partials/mobile-bottom-nav.blade.php new file mode 100644 index 0000000..98d9b82 --- /dev/null +++ b/resources/views/partials/mobile-bottom-nav.blade.php @@ -0,0 +1,137 @@ +@php + $gridCols = !empty($centerCompose) ? 'grid-cols-5' : 'grid-cols-4'; + $avatarUrl = $avatarUrl ?? null; + $initials = $initials ?? 'U'; + $notificationsUrl = $notificationsUrl ?? '#'; + $unreadUrl = $unreadUrl ?? null; + $profileUrl = $profileUrl ?? '#'; + $profileName = trim((string) ($profileName ?? '')); + $profileSubtitle = trim((string) ($profileSubtitle ?? '')); + $profileMenuItems = $profileMenuItems ?? []; + if ($profileMenuItems === [] && $profileUrl !== '#') { + $profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]]; + } + $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; +@endphp +
+ + + {{-- Profile menu bottom sheet (matches desktop avatar dropdown) --}} +
+
+ +
+
+ +
+ + @if ($profileName !== '' || $profileSubtitle !== '') +
+ @if ($avatarUrl) + + @else + {{ $initials }} + @endif +
+ @if ($profileName !== '') +

{{ $profileName }}

+ @endif + @if ($profileSubtitle !== '') +

{{ $profileSubtitle }}

+ @endif +
+
+ @endif + +
+ @foreach ($profileMenuItems as $item) + @if (($item['type'] ?? 'link') === 'link') + + {{ $item['label'] }} + + @elseif (($item['type'] ?? '') === 'logout') +
+ @csrf + +
+ @endif + @endforeach +
+
+
+
diff --git a/resources/views/partials/mobile-header-cart.blade.php b/resources/views/partials/mobile-header-cart.blade.php new file mode 100644 index 0000000..244bcd4 --- /dev/null +++ b/resources/views/partials/mobile-header-cart.blade.php @@ -0,0 +1,9 @@ +{{-- Mobile header cart icon — only include when the app has a shopping cart. --}} + + + @if (($cartCount ?? 0) > 0) + {{ $cartCount }} + @endif + diff --git a/resources/views/partials/notification-dropdown.blade.php b/resources/views/partials/notification-dropdown.blade.php new file mode 100644 index 0000000..8935cf0 --- /dev/null +++ b/resources/views/partials/notification-dropdown.blade.php @@ -0,0 +1,106 @@ +{{-- In-app notification bell + dropdown (scoped to this app). --}} + diff --git a/resources/views/partials/paystack-sheet.blade.php b/resources/views/partials/paystack-sheet.blade.php new file mode 100644 index 0000000..559bf73 --- /dev/null +++ b/resources/views/partials/paystack-sheet.blade.php @@ -0,0 +1,54 @@ +{{-- + Paystack mobile bottom-sheet iframe. + Requires Alpine.js ancestor with: showSheet (bool), checkoutUrl (string). + On mobile (< md) the sheet slides up; on desktop nothing renders. +--}} + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php new file mode 100644 index 0000000..4092c33 --- /dev/null +++ b/resources/views/partials/sidebar.blade.php @@ -0,0 +1,44 @@ +
+
+ + Ladill Servers + +
+ @php + $main = [ + ['name' => 'Dashboard', 'route' => route('servers.dashboard'), 'active' => request()->routeIs('servers.dashboard'), + 'icon' => ''], + ['name' => 'VPS', 'route' => route('servers.vps'), 'active' => request()->routeIs('servers.vps'), + 'icon' => ''], + ['name' => 'Dedicated', 'route' => route('servers.dedicated'), 'active' => request()->routeIs('servers.dedicated'), + 'icon' => ''], + ]; + @endphp + +
diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php new file mode 100644 index 0000000..2590aa5 --- /dev/null +++ b/resources/views/partials/topbar.blade.php @@ -0,0 +1,99 @@ +@php $u = auth()->user(); $acct = 'https://'.config('app.account_domain'); @endphp +
+
+ + {{-- Search hosting accounts, domains, orders --}} + +
+
+ @auth + @include('partials.notification-dropdown') + + + + {{-- Account switcher (only when the user belongs to more than one account) --}} + @if (isset($accessibleAccounts) && $accessibleAccounts->count() > 1) + + @endif + + {{-- Profile / avatar menu (desktop; mobile uses bottom nav) --}} + + + {{-- Afia (AI) — opens the in-app assistant. Matches the platform AI button. --}} + + @else + Sign in + @endauth + + @include('partials.launcher') +
+
diff --git a/resources/views/partials/wordpress-icon.blade.php b/resources/views/partials/wordpress-icon.blade.php new file mode 100644 index 0000000..a92f0e7 --- /dev/null +++ b/resources/views/partials/wordpress-icon.blade.php @@ -0,0 +1,2 @@ +@props(['class' => 'h-8 w-8']) +WordPress diff --git a/resources/views/servers/dashboard.blade.php b/resources/views/servers/dashboard.blade.php new file mode 100644 index 0000000..c7faabd --- /dev/null +++ b/resources/views/servers/dashboard.blade.php @@ -0,0 +1,96 @@ +@extends('layouts.hosting') +@section('title', 'Overview — Ladill Servers') +@section('content') +
+
+
+

Overview

+

Your VPS and dedicated servers at a glance.

+
+ +
+ +
+ @foreach([ + ['Active servers', $activeCount, route('servers.vps')], + ['VPS', $vpsCount, route('servers.vps')], + ['Dedicated', $dedicatedCount, route('servers.dedicated')], + ['Pending orders', $pendingOrderCount, route('servers.vps')], + ] as [$label, $value, $link]) + +

{{ $label }}

+

{{ $value }}

+
+ @endforeach +
+ + + + @if ($pendingOrders->isNotEmpty()) + + @endif +
+@endsection diff --git a/resources/views/servers/signed-out.blade.php b/resources/views/servers/signed-out.blade.php new file mode 100644 index 0000000..9e35022 --- /dev/null +++ b/resources/views/servers/signed-out.blade.php @@ -0,0 +1,17 @@ + + + + + Signed out — Ladill Servers + @include('partials.favicon') + @vite(['resources/css/app.css']) + + + +
+ Ladill Servers +

You’ve been signed out. Redirecting…

+ Go to ladill.com +
+ + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php new file mode 100644 index 0000000..d92ac19 --- /dev/null +++ b/resources/views/welcome.blade.php @@ -0,0 +1,278 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + @include('partials.favicon') + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

Laravel has an incredibly rich ecosystem.
We suggest starting with the following.

+ + +
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..bea963b --- /dev/null +++ b/routes/api.php @@ -0,0 +1,19 @@ +prefix('v1')->group(function () { + Route::get('/me', function (Request $request) { + $u = $request->user(); + + return ['id' => $u->public_id, 'name' => $u->name, 'email' => $u->email]; + }); + + Route::get('/mailboxes', function (Request $request, MailboxClient $mailboxes) { + return ['data' => $mailboxes->forUser((string) $request->user()->public_id)]; + }); +}); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3aede45 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,17 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); +Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping(); +Schedule::command('hosting:recalculate-node-capacity --shared-only')->daily()->withoutOverlapping(); +Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlapping(); +Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); +Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); +Schedule::command('ssl:renew')->daily()->withoutOverlapping(); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..574d59a --- /dev/null +++ b/routes/web.php @@ -0,0 +1,83 @@ + auth()->check() + ? redirect()->route('servers.dashboard') + : redirect()->route('sso.connect'))->name('servers.root'); + +Route::get('/login', [SsoLoginController::class, 'connect'])->name('login'); +Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect'); +Route::get('/sso/callback', [SsoLoginController::class, 'callback'])->name('sso.callback'); +Route::post('/logout', [SsoLoginController::class, 'logout'])->name('logout'); +Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannelLogout'])->name('sso.logout-frontchannel'); +Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('servers.dashboard') : view('servers.signed-out'))->name('servers.signed-out'); + +Route::middleware(['auth'])->group(function () { + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); + Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); + + Route::get('/search', SearchController::class)->name('servers.search'); + Route::get('/dashboard', [OverviewController::class, 'index'])->name('servers.dashboard'); + + Route::get('/vps', [HostingProductController::class, 'vps'])->name('servers.vps'); + Route::get('/dedicated', [HostingProductController::class, 'dedicated'])->name('servers.dedicated'); + + Route::redirect('/hosting/vps', '/vps'); + Route::redirect('/hosting/dedicated', '/dedicated'); + Route::redirect('/hosting', '/dashboard'); + + Route::get('/orders/{order}', [HostingProductController::class, 'show'])->name('servers.orders.show')->where('order', '[0-9]+'); + Route::get('/hosting/orders/{order}', fn (int $order) => redirect()->route('servers.orders.show', $order))->where('order', '[0-9]+'); + + Route::post('/orders', [HostingProductController::class, 'storeServer'])->name('servers.orders.store'); + Route::post('/hosting/orders', [HostingProductController::class, 'storeServer'])->name('hosting.servers.store'); + Route::post('/orders/{order}/cancel', [HostingProductController::class, 'cancel'])->name('servers.orders.cancel')->where('order', '[0-9]+'); + Route::post('/hosting/orders/{order}/cancel', [HostingProductController::class, 'cancel'])->name('hosting.orders.cancel')->where('order', '[0-9]+'); + + Route::prefix('orders/{order}/panel')->where(['order' => '[0-9]+'])->group(function () { + Route::get('/', [ServerPanelController::class, 'show'])->name('hosting.server-panel.show'); + Route::get('/{path}', [ServerPanelController::class, 'show'])->where('path', '.*')->name('hosting.server-panel.show.path'); + Route::post('/sync', [ServerPanelController::class, 'sync'])->name('hosting.server-panel.sync'); + Route::post('/power/{action}', [ServerPanelController::class, 'power'])->whereIn('action', ['start', 'stop', 'reboot'])->name('hosting.server-panel.power'); + Route::post('/tasks/domain', [ServerPanelController::class, 'queueDomain'])->name('hosting.server-panel.tasks.domain'); + Route::post('/tasks/database', [ServerPanelController::class, 'queueDatabase'])->name('hosting.server-panel.tasks.database'); + Route::post('/tasks/ssl', [ServerPanelController::class, 'queueSsl'])->name('hosting.server-panel.tasks.ssl'); + Route::post('/tasks/file', [ServerPanelController::class, 'queueFile'])->name('hosting.server-panel.tasks.file'); + Route::post('/tasks/php', [ServerPanelController::class, 'queuePhp'])->name('hosting.server-panel.tasks.php'); + Route::post('/tasks/site-delete', [ServerPanelController::class, 'queueSiteDelete'])->name('hosting.server-panel.tasks.site-delete'); + Route::post('/tasks/cron', [ServerPanelController::class, 'queueCron'])->name('hosting.server-panel.tasks.cron'); + Route::post('/tasks/log', [ServerPanelController::class, 'queueLog'])->name('hosting.server-panel.tasks.log'); + }); + + Route::get('/account/wallet', [AccountController::class, 'wallet'])->name('account.wallet'); + Route::get('/account/billing', [AccountController::class, 'billing'])->name('account.billing'); + Route::get('/account/settings', [AccountController::class, 'settings'])->name('account.settings'); + Route::put('/account/settings', [AccountController::class, 'updateSettings'])->name('account.settings.update'); + + Route::get('/account/team', [TeamController::class, 'index'])->name('account.team'); + Route::post('/account/team', [TeamController::class, 'store'])->name('account.team.store'); + Route::patch('/account/team/{member}/role', [TeamController::class, 'updateRole'])->name('account.team.role'); + Route::delete('/account/team/{member}', [TeamController::class, 'destroy'])->name('account.team.destroy'); + Route::post('/account/switch', [TeamController::class, 'switchAccount'])->name('account.switch'); + + Route::get('/account/developers', [DeveloperController::class, 'index'])->name('account.developers'); + Route::post('/account/developers', [DeveloperController::class, 'store'])->name('account.developers.store'); + Route::delete('/account/developers/{token}', [DeveloperController::class, 'destroy'])->whereNumber('token')->name('account.developers.destroy'); + + Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('servers.afia.chat'); +}); diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 0000000..fedb287 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,4 @@ +* +!private/ +!public/ +!.gitignore diff --git a/storage/app/private/.gitignore b/storage/app/private/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/private/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/app/public/.gitignore b/storage/app/public/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/public/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Feature/AccountPagesTest.php b/tests/Feature/AccountPagesTest.php new file mode 100644 index 0000000..de4a2d7 --- /dev/null +++ b/tests/Feature/AccountPagesTest.php @@ -0,0 +1,76 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_settings_page_renders(): void + { + $this->actingAs($this->user())->get(route('account.settings'))->assertOk()->assertSee('Hosting notifications'); + } + + public function test_team_page_renders_with_owner(): void + { + $u = $this->user(); + $this->actingAs($u)->get(route('account.team'))->assertOk()->assertSee('Owner')->assertSee($u->email); + } + + public function test_team_invite_creates_member(): void + { + $u = $this->user(); + $this->actingAs($u)->post(route('account.team.store'), ['email' => 'mate@x.com', 'role' => 'member']) + ->assertRedirect(); + $this->assertDatabaseHas('email_team_members', ['account_id' => $u->id, 'email' => 'mate@x.com', 'status' => 'invited']); + } + + public function test_developers_page_and_token_lifecycle(): void + { + $u = $this->user(); + $this->actingAs($u)->get(route('account.developers'))->assertOk()->assertSee('Create a token'); + $this->actingAs($u)->post(route('account.developers.store'), ['name' => 'CI']) + ->assertRedirect()->assertSessionHas('new_token'); + $this->assertSame(1, $u->tokens()->count()); + } + + public function test_billing_page_renders(): void + { + $w = Mockery::mock(WalletPaymentService::class); + $w->shouldReceive('balanceMinor')->andReturn(5000); + $this->app->instance(WalletPaymentService::class, $w); + + $b = Mockery::mock(BillingClient::class); + $b->shouldReceive('serviceLedger')->andReturn(['spent_minor' => 1000, 'credited_minor' => 0]); + $b->shouldReceive('balanceMinor')->andReturn(5000); + $this->app->instance(BillingClient::class, $b); + + $this->actingAs($this->user())->get(route('account.billing')) + ->assertOk()->assertSee('Spent on hosting'); + } + + public function test_switch_account_rejects_inaccessible(): void + { + $this->actingAs($this->user())->post(route('account.switch'), ['account' => 999999])->assertForbidden(); + } +} diff --git a/tests/Feature/AdminHostingAccountDurationTest.php b/tests/Feature/AdminHostingAccountDurationTest.php new file mode 100644 index 0000000..9261021 --- /dev/null +++ b/tests/Feature/AdminHostingAccountDurationTest.php @@ -0,0 +1,273 @@ +create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $response = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [ + 'hosting_product_id' => $product->id, + 'duration_months' => 6, + 'primary_domain' => 'example.com', + 'username' => 'exampleuser', + ]); + + $response->assertRedirect(); + + $account = HostingAccount::query()->where('user_id', $user->id)->firstOrFail(); + + $this->assertSame('exampleuser', $account->username); + $this->assertSame('example.com', $account->primary_domain); + $this->assertSame($product->id, $account->hosting_product_id); + $this->assertSame('active', $account->status); + $this->assertNotNull($account->expires_at); + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-10-05 10:00:00'))); + $this->assertSame(6, data_get($account->metadata, 'assigned_duration_months')); + } + + public function test_admin_can_assign_same_hosting_product_multiple_times_to_one_user(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $admin = User::factory()->create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $first = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [ + 'hosting_product_id' => $product->id, + 'duration_months' => 6, + 'primary_domain' => 'first-example.com', + 'username' => 'firstacct', + ]); + + $second = $this->actingAs($admin)->post(route('admin.users.assign-hosting-package', $user), [ + 'hosting_product_id' => $product->id, + 'duration_months' => 12, + 'primary_domain' => 'second-example.com', + 'username' => 'secondacct', + ]); + + $first->assertRedirect(); + $second->assertRedirect(); + + $accounts = HostingAccount::query() + ->where('user_id', $user->id) + ->where('hosting_product_id', $product->id) + ->orderBy('username') + ->get(); + + $this->assertCount(2, $accounts); + $this->assertSame(['firstacct', 'secondacct'], $accounts->pluck('username')->all()); + $this->assertSame(['first-example.com', 'second-example.com'], $accounts->pluck('primary_domain')->all()); + } + + public function test_admin_can_update_duration_for_existing_hosting_account(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $admin = User::factory()->create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::query()->create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'existinguser', + 'type' => 'shared', + 'status' => 'active', + 'expires_at' => Carbon::parse('2026-05-05 10:00:00'), + 'metadata' => [ + 'assigned_duration_months' => 1, + ], + ]); + + $response = $this->actingAs($admin)->patch(route('admin.users.hosting-accounts.update-duration', [$user, $account]), [ + 'duration_months' => 12, + ]); + + $response->assertRedirect(); + + $account->refresh(); + + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00'))); + $this->assertSame(12, data_get($account->metadata, 'assigned_duration_months')); + $this->assertSame($admin->id, data_get($account->metadata, 'duration_updated_by_admin')); + $this->assertNotNull(data_get($account->metadata, 'duration_updated_at')); + } + + public function test_admin_can_renew_existing_hosting_account_using_assigned_duration(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $admin = User::factory()->create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::query()->create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'renewadmin', + 'type' => 'shared', + 'status' => 'active', + 'expires_at' => Carbon::parse('2026-10-05 10:00:00'), + 'metadata' => [ + 'assigned_duration_months' => 6, + ], + ]); + + $response = $this->actingAs($admin)->post(route('admin.users.hosting-accounts.renew', [$user, $account])); + + $response->assertRedirect(); + + $account->refresh(); + + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2027-04-05 10:00:00'))); + $this->assertSame($admin->id, data_get($account->metadata, 'renewed_by_admin')); + $this->assertNotNull(data_get($account->metadata, 'renewed_at')); + } + + public function test_customer_renewal_starts_payment_checkout_instead_of_renewing_immediately(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::query()->create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'renewcustomer', + 'type' => 'shared', + 'status' => 'active', + 'expires_at' => Carbon::parse('2026-05-05 10:00:00'), + 'metadata' => [ + 'assigned_duration_months' => 3, + ], + ]); + + $paystack = \Mockery::mock(PaystackService::class); + $paystack->shouldReceive('initializeTransaction') + ->once() + ->andReturn([ + 'authorization_url' => 'https://paystack.test/renew/authorize', + ]); + $this->app->instance(PaystackService::class, $paystack); + + $response = $this->actingAs($user)->post(route('hosting.accounts.renew', $account)); + + $response->assertRedirect('https://paystack.test/renew/authorize'); + + $account->refresh(); + + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-05-05 10:00:00'))); + $this->assertNull(data_get($account->metadata, 'renewed_by_user')); + $this->assertDatabaseHas('hosting_billing_invoices', [ + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL, + 'status' => HostingBillingInvoice::STATUS_PENDING, + 'total_amount' => 30, + ]); + } + + public function test_customer_renewal_callback_applies_extension_after_successful_payment(): void + { + Carbon::setTestNow('2026-04-05 10:00:00'); + + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::query()->create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'renewcallback', + 'type' => 'shared', + 'status' => 'active', + 'expires_at' => Carbon::parse('2026-05-05 10:00:00'), + 'metadata' => [ + 'assigned_duration_months' => 3, + ], + ]); + + $invoice = HostingBillingInvoice::query()->create([ + 'user_id' => $user->id, + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_RENEWAL, + 'status' => HostingBillingInvoice::STATUS_PENDING, + 'currency' => 'GHS', + 'subtotal_amount' => 30, + 'credit_amount' => 0, + 'total_amount' => 30, + 'provider_reference' => 'HOST-REN-CALLBACK', + 'metadata' => [ + 'months' => 3, + 'billing_cycle' => 'quarterly', + ], + ]); + + $paystack = \Mockery::mock(PaystackService::class); + $paystack->shouldReceive('verifyTransaction') + ->once() + ->with('HOST-REN-CALLBACK') + ->andReturn([ + 'reference' => 'HOST-REN-CALLBACK', + 'status' => 'success', + ]); + $this->app->instance(PaystackService::class, $paystack); + + $response = $this->actingAs($user)->get(route('hosting.accounts.renew.callback', $account) . '?reference=HOST-REN-CALLBACK'); + + $response->assertRedirect(route('hosting.accounts.show', $account)); + + $account->refresh(); + $invoice->refresh(); + + $this->assertTrue($account->expires_at->equalTo(Carbon::parse('2026-08-05 10:00:00'))); + $this->assertSame($user->id, data_get($account->metadata, 'renewed_by_user')); + $this->assertNotNull($invoice->paid_at); + $this->assertSame(HostingBillingInvoice::STATUS_PAID, $invoice->status); + } + + private function createHostingProduct(): HostingProduct + { + return HostingProduct::query()->create([ + 'name' => 'Admin Assigned Shared Hosting', + 'slug' => 'admin-assigned-shared-hosting', + 'category' => 'shared', + 'type' => 'single_domain', + 'price_monthly' => 10, + 'price_quarterly' => 30, + 'price_yearly' => 120, + 'price_biennial' => 240, + 'currency' => 'GHS', + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + 'sort_order' => 1, + ]); + } +} diff --git a/tests/Feature/AdminHostingAccountUnsuspendTest.php b/tests/Feature/AdminHostingAccountUnsuspendTest.php new file mode 100644 index 0000000..a988764 --- /dev/null +++ b/tests/Feature/AdminHostingAccountUnsuspendTest.php @@ -0,0 +1,142 @@ +create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'unsuspendacct', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_SUSPENDED, + 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, + 'suspension_reason' => 'Manual suspension.', + 'suspended_at' => now()->subHour(), + 'metadata' => [ + 'suspension_origin' => 'manual', + ], + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_account_id' => $account->id, + 'domain_name' => 'example.test', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 10, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_SUSPENDED, + 'suspended_at' => now()->subHour(), + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('unsuspendAccount')->once()->withArgs( + fn (HostingAccount $candidate): bool => $candidate->id === $account->id + )->andReturn(true); + $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( + fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false + )->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + $response = $this->actingAs($admin)->post(route('admin.users.hosting-accounts.unsuspend', [$user, $account])); + + $response->assertRedirect(); + $response->assertSessionHas('success', 'Hosting account unsuspended.'); + + $account->refresh(); + $order->refresh(); + + $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); + $this->assertNull($account->suspended_at); + $this->assertNull($account->suspension_reason); + $this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status); + $this->assertNull($order->suspended_at); + } + + public function test_admin_order_unsuspend_works_when_account_is_suspended_but_order_status_drifted(): void + { + $admin = User::factory()->create(['is_admin' => true]); + $user = User::factory()->create(); + $product = $this->createHostingProduct(); + + $account = HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'driftacct', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_SUSPENDED, + 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, + 'suspension_reason' => 'CPU overage.', + 'suspended_at' => now()->subMinutes(30), + 'metadata' => [ + 'suspension_origin' => 'automatic', + ], + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_account_id' => $account->id, + 'domain_name' => 'drift.example.test', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 10, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('unsuspendAccount')->once()->withArgs( + fn (HostingAccount $candidate): bool => $candidate->id === $account->id + )->andReturn(true); + $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( + fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false + )->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + $response = $this->actingAs($admin)->post(route('admin.hosting.orders.unsuspend', $order)); + + $response->assertRedirect(); + $response->assertSessionHas('success', 'Order unsuspended.'); + + $account->refresh(); + $order->refresh(); + + $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); + $this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status); + } + + private function createHostingProduct(): HostingProduct + { + return HostingProduct::create([ + 'name' => 'Admin Unsuspend Test', + 'slug' => 'admin-unsuspend-test-' . str()->random(6), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + } +} diff --git a/tests/Feature/HardenHostingAccountIsolationCommandTest.php b/tests/Feature/HardenHostingAccountIsolationCommandTest.php new file mode 100644 index 0000000..51354b2 --- /dev/null +++ b/tests/Feature/HardenHostingAccountIsolationCommandTest.php @@ -0,0 +1,85 @@ +create(); + $product = HostingProduct::create([ + 'name' => 'Isolation Test Product', + 'slug' => 'isolation-test-' . str()->random(6), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $account = HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'isolateacct', + 'primary_domain' => 'example.test', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('hardenAccountFilesystem')->once()->withArgs( + fn (HostingAccount $candidate): bool => $candidate->id === $account->id + ); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->artisan('hosting:harden-account-isolation', [ + '--account' => $account->id, + ])->assertExitCode(0); + } + + public function test_it_supports_dry_run_without_touching_accounts(): void + { + $user = User::factory()->create(); + $product = HostingProduct::create([ + 'name' => 'Isolation Test Product', + 'slug' => 'isolation-dry-run-' . str()->random(6), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'dryrunacct', + 'primary_domain' => 'dry-run.test', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldNotReceive('hardenAccountFilesystem'); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->artisan('hosting:harden-account-isolation', [ + '--dry-run' => true, + ])->assertExitCode(0); + } +} diff --git a/tests/Feature/HostingOrderFulfillmentServiceTest.php b/tests/Feature/HostingOrderFulfillmentServiceTest.php new file mode 100644 index 0000000..8f57891 --- /dev/null +++ b/tests/Feature/HostingOrderFulfillmentServiceTest.php @@ -0,0 +1,107 @@ +create(); + $product = HostingProduct::create([ + 'name' => 'Cloud VPS', + 'slug' => 'vps-fulfillment', + 'category' => HostingProduct::CATEGORY_VPS, + 'type' => HostingProduct::TYPE_VPS, + 'contabo_product_id' => 'V91', + 'price_monthly' => 50, + 'currency' => 'GHS', + 'disk_gb' => 75, + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'vps-server', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 50, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL, + ]); + + $result = app(HostingOrderFulfillmentService::class)->attemptAutoFulfillment($order); + + $this->assertTrue($result['fulfilled']); + $order->refresh(); + $this->assertSame(CustomerHostingOrder::STATUS_APPROVED, $order->status); + Bus::assertDispatched(ProvisionHostingOrderJob::class); + } + + public function test_customer_facing_status_hides_approval_wording(): void + { + $this->assertSame('Pending', CustomerHostingOrder::customerFacingStatusLabelFor( + CustomerHostingOrder::STATUS_PENDING_APPROVAL + )); + $this->assertSame('Pending', CustomerHostingOrder::customerFacingStatusLabelFor( + CustomerHostingOrder::STATUS_APPROVED + )); + } + + public function test_contabo_402_is_treated_as_payment_required(): void + { + $exception = ContaboApiException::fromResponse(402, json_encode([ + 'message' => 'Request refused as it requires additional payed service.', + ], JSON_THROW_ON_ERROR)); + + $this->assertTrue($exception->isPaymentRequired()); + } + + public function test_order_returns_to_pending_approval_on_contabo_payment_failure(): void + { + $user = User::factory()->create(); + $product = HostingProduct::create([ + 'name' => 'Cloud VPS', + 'slug' => 'vps-hold', + 'category' => HostingProduct::CATEGORY_VPS, + 'type' => HostingProduct::TYPE_VPS, + 'contabo_product_id' => 'V91', + 'price_monthly' => 50, + 'currency' => 'GHS', + 'disk_gb' => 75, + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'vps-hold', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 50, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_PROVISIONING, + ]); + + $order->holdForAdminProvisioning('Request refused as it requires additional payed service.', 402); + + $order->refresh(); + $this->assertSame(CustomerHostingOrder::STATUS_PENDING_APPROVAL, $order->status); + $this->assertSame(402, $order->meta['provisioning_hold']['http_status'] ?? null); + $this->assertSame('Pending', $order->customerFacingStatusLabel()); + } +} diff --git a/tests/Feature/HostingPanelAppInstallTest.php b/tests/Feature/HostingPanelAppInstallTest.php new file mode 100644 index 0000000..c1be657 --- /dev/null +++ b/tests/Feature/HostingPanelAppInstallTest.php @@ -0,0 +1,306 @@ +create(); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'example.com', + 'document_root' => '/home/acctuser/public_html', + 'type' => 'primary', + 'status' => 'active', + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('createDatabase') + ->once() + ->withArgs(fn ($database, string $password): bool => $database->hosting_account_id === $account->id && $database->hosted_site_id === $site->id && $database->name === $database->username && strlen($password) === 16) + ->andReturn(['name' => 'acctuser_abcd12', 'username' => 'acctuser_abcd12', 'host' => 'localhost']); + $provider->shouldReceive('deleteDatabase') + ->once() + ->andReturn(true); + $provider->shouldReceive('prepareDirectoryForUser') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $path): bool => $passedAccount->is($account) && $path === '/home/acctuser/public_html'); + $provider->shouldReceive('executeCommand') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $command): bool => $passedAccount->is($account) && str_starts_with($command, 'test -w ')) + ->andReturn(['output' => '', 'exit_code' => 0]); + $provider->shouldReceive('executeCommand') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $command): bool => $passedAccount->is($account) && str_contains($command, 'curl -fLsS')) + ->andReturn(['output' => '', 'exit_code' => 6]); + + $this->app->instance(SharedNodeProvider::class, $provider); + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/apps/install', 'POST', [ + 'app' => 'wordpress', + 'site_id' => $site->id, + 'directory' => '', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/apps', + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->installApp($request, $account); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'Failed to install application: WordPress installation failed. Download WordPress package failed with exit code 6.', + $session->get('error') + ); + + $this->assertNull($site->fresh()->installed_app); + $this->assertSame(0, $account->fresh()->databases()->count()); + } + + public function test_wordpress_install_provisions_database_and_returns_admin_credentials(): void + { + $user = User::factory()->create(['email' => 'owner@example.com']); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'example.com', + 'document_root' => '/home/acctuser/public_html', + 'type' => 'primary', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('createDatabase') + ->once() + ->withArgs(fn ($database, string $password): bool => $database->hosting_account_id === $account->id && $database->hosted_site_id === $site->id && $database->name === $database->username && strlen($password) === 16) + ->andReturnUsing(fn ($database) => ['name' => $database->name, 'username' => $database->username, 'host' => 'localhost']); + $provider->shouldReceive('prepareDirectoryForUser') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $path): bool => $passedAccount->is($account) && $path === '/home/acctuser/public_html'); + $provider->shouldReceive('executeCommand') + ->times(10) + ->andReturnUsing(function (HostingAccount $passedAccount, string $command) use ($account) { + $this->assertTrue($passedAccount->is($account)); + + return match (true) { + str_starts_with($command, 'test -w ') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'https://wordpress.org/latest.tar.gz') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'tar -xzf latest.tar.gz') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'rm -f latest.tar.gz') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'php wp-cli.phar config create') => ['output' => 'Success: Generated wp-config.php file.', 'exit_code' => 0], + str_contains($command, 'php wp-cli.phar core install') => ['output' => 'Success: WordPress installed successfully.', 'exit_code' => 0], + str_contains($command, "php wp-cli.phar rewrite structure '/%postname%/'") => ['output' => 'Success: Rewrite structure set.', 'exit_code' => 0], + str_contains($command, 'rm -f wp-cli.phar') => ['output' => '', 'exit_code' => 0], + str_contains($command, 'wp-load.php') && str_contains($command, 'wp-config.php') => ['output' => '', 'exit_code' => 0], + default => throw new \RuntimeException("Unexpected command: {$command}"), + }; + }); + $provider->shouldNotReceive('deleteDatabase'); + + $this->app->instance(SharedNodeProvider::class, $provider); + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/apps/install', 'POST', [ + 'app' => 'wordpress', + 'site_id' => $site->id, + 'directory' => '', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/apps', + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->installApp($request, $account); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertStringContainsString('Wordpress installed successfully in example.com/', $session->get('success')); + $this->assertStringContainsString('Admin URL: http://example.com/wp-admin', $session->get('success')); + $this->assertStringContainsString('Database:', $session->get('success')); + + $site = $site->fresh(); + $this->assertSame('wordpress', $site->installed_app); + $this->assertSame('http://example.com/wp-admin', $site->app_config['admin_url'] ?? null); + $this->assertNotNull($site->app_config['database_name'] ?? null); + $this->assertNotNull($site->app_config['database_username'] ?? null); + $this->assertSame(1, $account->fresh()->databases()->count()); + } + + public function test_database_panel_shows_configured_phpmyadmin_link(): void + { + Config::set('hosting.shared.phpmyadmin_url', 'https://db.example.com/?account={account}&domain={primary_domain}'); + + $user = User::factory()->create(); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/databases', 'GET'); + $request->setUserResolver(fn (): User => $user); + + $response = $this->app->make(HostingPanelController::class)->databases($request, $account); + + $this->assertSame('https://db.example.com/?account=acctuser&domain=example.com', $response->getData()['phpMyAdminUrl']); + } + + public function test_extract_file_uses_quiet_archive_commands_for_large_zip_uploads(): void + { + $user = User::factory()->create(); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $archivePath = '/public_html/archive.zip'; + $fullArchivePath = '/home/acctuser/public_html/archive.zip'; + $archiveDir = '/home/acctuser/public_html'; + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('executeCommand') + ->once() + ->ordered() + ->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account) + && $command === "unzip -tqq '{$fullArchivePath}' 2>&1" + && $timeout === 1800) + ->andReturn(['output' => '', 'exit_code' => 0]); + $provider->shouldReceive('executeCommand') + ->once() + ->ordered() + ->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account) + && $command === "unzip -Z1 '{$fullArchivePath}' 2>/dev/null | wc -l" + && $timeout === 1800) + ->andReturn(['output' => "423\n", 'exit_code' => 0]); + $provider->shouldReceive('executeCommand') + ->once() + ->ordered() + ->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account) + && $command === "cd '{$archiveDir}' && unzip -oq '{$fullArchivePath}' 2>&1" + && $timeout === 1800) + ->andReturn(['output' => '', 'exit_code' => 0]); + $provider->shouldReceive('executeCommand') + ->once() + ->ordered() + ->withArgs(fn (HostingAccount $passedAccount, string $command, int $timeout): bool => $passedAccount->is($account) + && $command === "find '{$archiveDir}' -type f -exec chmod 644 {} + 2>/dev/null; find '{$archiveDir}' -type d -exec chmod 755 {} + 2>/dev/null" + && $timeout === 1800) + ->andReturn(['output' => '', 'exit_code' => 0]); + + $this->app->instance(SharedNodeProvider::class, $provider); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/files/extract', 'POST', [ + 'path' => $archivePath, + ]); + $request->setUserResolver(fn (): User => $user); + + $response = $this->app->make(HostingPanelController::class)->extractFile($request, $account); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([ + 'success' => true, + 'message' => 'Archive extracted successfully', + 'total_files' => 423, + 'extracted_files' => 423, + ], $response->getData(true)); + } +} diff --git a/tests/Feature/HostingPanelDomainLinkingTest.php b/tests/Feature/HostingPanelDomainLinkingTest.php new file mode 100644 index 0000000..768d35a --- /dev/null +++ b/tests/Feature/HostingPanelDomainLinkingTest.php @@ -0,0 +1,263 @@ +create(); + + $product = HostingProduct::create([ + 'name' => 'Multi Domain Hosting', + 'slug' => 'multi-domain-hosting-test', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 10, + 'max_domains' => 5, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node, $product) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'primary.example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('addSite') + ->once() + ->withArgs(fn (HostedSite $site): bool => $site->hosting_account_id === $account->id && $site->domain === 'linked-example.com') + ->andReturn([ + 'document_root' => '/home/acctuser/public_html/linked-example.com', + 'domain' => 'linked-example.com', + ]); + + $verification = \Mockery::mock(DomainVerificationService::class); + $verification->shouldNotReceive('prepareNameserverZone'); + $verification->shouldNotReceive('prepareManualDnsPack'); + + $this->app->instance(SharedNodeProvider::class, $provider); + $this->app->instance(DomainVerificationService::class, $verification); + + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/domains', 'POST', [ + 'domain' => 'linked-example.com', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/accounts/' . $account->id, + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->addDomain( + $request, + $account, + $this->app->make(DomainDnsBlueprintService::class), + $this->app->make(DomainVerificationService::class), + ); + + $this->assertSame(302, $response->getStatusCode()); + + $domain = Domain::query()->where('host', 'linked-example.com')->firstOrFail(); + + $this->assertSame($account->id, $domain->hosting_account_id); + $this->assertSame(Domain::MODE_NS_AUTO, $domain->onboarding_mode); + $this->assertSame('managed', $domain->dns_mode); + + $this->assertDatabaseHas('hosted_sites', [ + 'hosting_account_id' => $account->id, + 'domain' => 'linked-example.com', + 'domain_id' => $domain->id, + ]); + } + + public function test_hosting_account_page_renders_linked_domains(): void + { + $user = User::factory()->create(); + + $product = HostingProduct::create([ + 'name' => 'Multi Domain Hosting', + 'slug' => 'multi-domain-account-view-test', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 10, + 'max_domains' => 3, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node, $product) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'primary.example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'primary.example.com', + 'document_root' => '/home/acctuser/public_html', + 'type' => 'primary', + 'status' => 'active', + 'ssl_enabled' => true, + ]); + + HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'linked-example.com', + 'document_root' => '/home/acctuser/public_html/linked-example.com', + 'type' => 'addon', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $request = Request::create('http://localhost/hosting/accounts/' . $account->id, 'GET'); + $request->setUserResolver(fn (): User => $user); + + $view = $this->app->make(HostingProductController::class)->showAccount($request, $account); + $html = $view->render(); + + $this->assertStringContainsString('primary.example.com', $html); + $this->assertStringContainsString('linked-example.com', $html); + $this->assertStringContainsString('2 of 3 domain slots in use.', $html); + } + + public function test_add_domain_does_not_restore_soft_deleted_site_from_another_account(): void + { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + $product = HostingProduct::create([ + 'name' => 'Multi Domain Hosting', + 'slug' => 'multi-domain-restore-guard-test', + 'category' => 'shared', + 'type' => 'multi_domain', + 'price_monthly' => 10, + 'max_domains' => 5, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node, $product) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'primary.example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $otherAccount = HostingAccount::unguarded(function () use ($otherUser, $node, $product) { + return HostingAccount::create([ + 'user_id' => $otherUser->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'otheracct', + 'primary_domain' => 'other.example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $site = HostedSite::create([ + 'hosting_account_id' => $otherAccount->id, + 'domain' => 'linked-example.com', + 'document_root' => '/home/otheracct/public_html/linked-example.com', + 'type' => 'addon', + 'status' => 'active', + ]); + $site->delete(); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldNotReceive('addSite'); + $this->app->instance(SharedNodeProvider::class, $provider); + + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create('http://localhost/hosting/panel/' . $account->id . '/domains', 'POST', [ + 'domain' => 'linked-example.com', + ], [], [], [ + 'HTTP_REFERER' => 'http://localhost/hosting/accounts/' . $account->id, + ]); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->addDomain( + $request, + $account, + $this->app->make(DomainDnsBlueprintService::class), + $this->app->make(DomainVerificationService::class), + ); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('This domain is already in use on another hosting account.', $session->get('error')); + $this->assertSoftDeleted('hosted_sites', [ + 'id' => $site->id, + 'hosting_account_id' => $otherAccount->id, + 'domain' => 'linked-example.com', + ]); + } +} diff --git a/tests/Feature/HostingPanelSslTest.php b/tests/Feature/HostingPanelSslTest.php new file mode 100644 index 0000000..89b4e07 --- /dev/null +++ b/tests/Feature/HostingPanelSslTest.php @@ -0,0 +1,150 @@ +create(); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'example.com', + 'document_root' => '/home/acctuser/public_html/example.com', + 'type' => 'primary', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('requestLetsEncryptCertificate') + ->once() + ->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site)) + ->andReturn(true); + + $this->app->instance(SharedNodeProvider::class, $provider); + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create( + 'http://localhost/hosting/panel/' . $account->id . '/ssl/' . $site->id, + 'POST', + [], + [], + [], + ['HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/ssl'] + ); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->requestSsl($request, $account, $site); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'SSL certificate issued for example.com successfully.', + $session->get('success') + ); + $this->assertTrue((bool) $site->fresh()->ssl_enabled); + } + + public function test_ssl_request_surfaces_actionable_provider_failures(): void + { + $user = User::factory()->create(); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node', + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + $account = HostingAccount::unguarded(function () use ($user, $node) { + return HostingAccount::create([ + 'user_id' => $user->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'example.com', + 'document_root' => '/home/acctuser/public_html/example.com', + 'type' => 'primary', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('requestLetsEncryptCertificate') + ->once() + ->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site)) + ->andThrow(new \RuntimeException('Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.')); + + $this->app->instance(SharedNodeProvider::class, $provider); + $session = $this->app['session.store']; + $session->start(); + + $request = Request::create( + 'http://localhost/hosting/panel/' . $account->id . '/ssl/' . $site->id, + 'POST', + [], + [], + [], + ['HTTP_REFERER' => 'http://localhost/hosting/panel/' . $account->id . '/ssl'] + ); + $request->setUserResolver(fn (): User => $user); + $request->setLaravelSession($session); + + $response = $this->app->make(HostingPanelController::class)->requestSsl($request, $account, $site); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'Failed to issue SSL certificate: Local hosting administration requires passwordless sudo or a valid root SSH key on the hosting node.', + $session->get('error') + ); + $this->assertFalse((bool) $site->fresh()->ssl_enabled); + } +} diff --git a/tests/Feature/HostingPanelTerminalTest.php b/tests/Feature/HostingPanelTerminalTest.php new file mode 100644 index 0000000..1919b98 --- /dev/null +++ b/tests/Feature/HostingPanelTerminalTest.php @@ -0,0 +1,158 @@ +create(); + $account = $this->createHostingAccount($owner); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('executeTerminalCommand') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $command, string $workingDirectory) => $passedAccount->is($account) + && $command === 'php artisan about' + && $workingDirectory === '/home/acctuser/public_html') + ->andReturn([ + 'output' => 'Laravel Framework 12.x', + 'exit_code' => 0, + ]); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($owner) + ->get(route('hosting.panel.terminal', $account)) + ->assertOk() + ->assertSee('Terminal') + ->assertSee($account->username.'@ladill'); + + $this->actingAs($owner) + ->postJson(route('hosting.panel.terminal.run', $account), [ + 'command' => 'php artisan about', + 'path' => '/public_html', + ]) + ->assertOk() + ->assertJson([ + 'success' => true, + 'exit_code' => 0, + 'output' => 'Laravel Framework 12.x', + 'cwd' => '/public_html', + ]); + } + + public function test_terminal_rejects_disallowed_commands_before_execution(): void + { + $owner = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldNotReceive('executeTerminalCommand'); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($owner) + ->postJson(route('hosting.panel.terminal.run', $account), [ + 'command' => 'curl https://example.com', + 'path' => '/public_html', + ]) + ->assertStatus(422) + ->assertJsonPath('error', '`curl` is not available in the panel terminal.'); + } + + public function test_developer_can_view_terminal(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + $this->actingAs($developer) + ->get(route('hosting.panel.terminal', $account)) + ->assertOk() + ->assertSee('Terminal'); + } + + public function test_terminal_input_endpoint_accepts_plain_text_payloads(): void + { + $owner = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + $sessionManager = \Mockery::mock(BrowserTerminalSessionManager::class); + $sessionManager->shouldReceive('appendInput') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, int $userId, string $sessionId, string $input) => $passedAccount->is($account) + && $userId === (int) $owner->id + && $sessionId === 'session-123' + && $input === "ls\r"); + $this->app->instance(BrowserTerminalSessionManager::class, $sessionManager); + + $this->actingAs($owner) + ->call( + 'POST', + route('hosting.panel.terminal.sessions.input', [$account, 'session-123']), + server: [ + 'CONTENT_TYPE' => 'text/plain', + 'HTTP_ACCEPT' => 'application/json', + ], + content: "ls\r", + ) + ->assertOk() + ->assertJson(['ok' => true]); + } + + private function createHostingAccount(User $owner): HostingAccount + { + $product = HostingProduct::create([ + 'name' => 'Single Domain Hosting', + 'slug' => 'terminal-test-'.$owner->id, + 'category' => 'shared', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node-terminal-'.$owner->id, + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + return HostingAccount::unguarded(function () use ($owner, $product, $node) { + return HostingAccount::create([ + 'user_id' => $owner->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acctuser', + 'primary_domain' => 'example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + } +} diff --git a/tests/Feature/HostingPhaseThreeApiTest.php b/tests/Feature/HostingPhaseThreeApiTest.php new file mode 100644 index 0000000..4c9383a --- /dev/null +++ b/tests/Feature/HostingPhaseThreeApiTest.php @@ -0,0 +1,374 @@ +create(); + + $visible = $this->makeProduct([ + 'name' => 'Starter Shared', + 'slug' => 'starter-shared-phase3', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + ]); + + $hidden = $this->makeProduct([ + 'name' => 'Hidden Shared', + 'slug' => 'hidden-shared-phase3', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_MULTI_DOMAIN, + 'is_visible' => false, + ]); + + $this->actingAs($user) + ->getJson('/api/plans') + ->assertOk() + ->assertJsonPath('data.0.id', $visible->id) + ->assertJsonMissing(['id' => $hidden->id]); + } + + public function test_upgrade_endpoint_applies_prorated_plan_change_and_updates_quotas(): void + { + Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0)); + + $user = User::factory()->create(); + $currentProduct = $this->makeProduct([ + 'name' => 'Starter', + 'slug' => 'starter-phase3-upgrade', + 'price_monthly' => 10, + 'disk_gb' => 10, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 5, + ]); + $targetProduct = $this->makeProduct([ + 'name' => 'Growth', + 'slug' => 'growth-phase3-upgrade', + 'price_monthly' => 25, + 'disk_gb' => 25, + 'max_domains' => 5, + 'max_databases' => 10, + 'max_email_accounts' => 15, + ]); + + $account = $this->makeAccount($user, $currentProduct, [ + 'allocated_disk_gb' => 10, + 'expires_at' => now()->addMonth(), + 'resource_limits' => [ + 'disk_gb' => 10, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 5, + ], + ]); + + CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $currentProduct->id, + 'hosting_account_id' => $account->id, + 'domain_name' => $account->primary_domain, + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 10, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + 'expires_at' => now()->addMonth(), + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('syncAccountPackage')->once()->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($user) + ->postJson('/api/hosting/upgrade', [ + 'hosting_account_id' => $account->id, + 'target_product_id' => $targetProduct->id, + ]) + ->assertOk() + ->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_UPGRADE) + ->assertJsonPath('data.quote.charge_amount', 15) + ->assertJsonPath('data.invoice.total_amount', '15.00'); + + $account->refresh(); + + $this->assertSame($targetProduct->id, $account->hosting_product_id); + $this->assertSame(25, $account->allocated_disk_gb); + $this->assertSame(5, data_get($account->resource_limits, 'max_domains')); + $this->assertSame(15, data_get($account->resource_limits, 'max_email_accounts')); + + $this->assertDatabaseHas('hosting_plan_changes', [ + 'hosting_account_id' => $account->id, + 'direction' => HostingPlanChange::DIRECTION_UPGRADE, + 'charge_amount' => 15, + 'credit_amount' => 0, + ]); + + $this->assertDatabaseHas('hosting_billing_invoices', [ + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE, + 'status' => HostingBillingInvoice::STATUS_PENDING, + 'total_amount' => 15, + ]); + } + + public function test_downgrade_endpoint_applies_credit_and_reduces_quota_immediately(): void + { + Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0)); + + $user = User::factory()->create(); + $currentProduct = $this->makeProduct([ + 'name' => 'Growth', + 'slug' => 'growth-phase3-downgrade', + 'price_monthly' => 25, + 'disk_gb' => 25, + 'max_domains' => 5, + 'max_databases' => 10, + 'max_email_accounts' => 15, + ]); + $targetProduct = $this->makeProduct([ + 'name' => 'Starter', + 'slug' => 'starter-phase3-downgrade', + 'price_monthly' => 10, + 'disk_gb' => 10, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 5, + ]); + + $account = $this->makeAccount($user, $currentProduct, [ + 'allocated_disk_gb' => 25, + 'expires_at' => now()->addMonth(), + 'resource_limits' => [ + 'disk_gb' => 25, + 'max_domains' => 5, + 'max_databases' => 10, + 'max_email_accounts' => 15, + ], + ]); + + CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $currentProduct->id, + 'hosting_account_id' => $account->id, + 'domain_name' => $account->primary_domain, + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 25, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + 'expires_at' => now()->addMonth(), + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('syncAccountPackage')->once()->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($user) + ->postJson('/api/hosting/downgrade', [ + 'hosting_account_id' => $account->id, + 'target_product_id' => $targetProduct->id, + ]) + ->assertOk() + ->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_DOWNGRADE) + ->assertJsonPath('data.quote.credit_amount', 15) + ->assertJsonPath('data.invoice.status', HostingBillingInvoice::STATUS_CREDITED); + + $account->refresh(); + + $this->assertSame($targetProduct->id, $account->hosting_product_id); + $this->assertSame(10, $account->allocated_disk_gb); + $this->assertDatabaseHas('hosting_billing_invoices', [ + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE, + 'status' => HostingBillingInvoice::STATUS_CREDITED, + 'credit_amount' => 15, + 'total_amount' => 0, + ]); + } + + public function test_paid_hosting_mailbox_creation_creates_addon_invoice(): void + { + $user = User::factory()->create(); + $product = $this->makeProduct([ + 'name' => 'Starter Mail', + 'slug' => 'starter-mail-phase3', + 'max_email_accounts' => 1, + ]); + $account = $this->makeAccount($user, $product); + $domain = $this->makeDomain($user, $account, 'phase3-mail.test'); + + Mailbox::create([ + 'user_id' => $user->id, + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'display_name' => 'Free Mailbox', + 'local_part' => 'info', + 'address' => 'info@phase3-mail.test', + 'password_hash' => Hash::make('password123'), + 'quota_mb' => 2048, + 'status' => 'active', + 'maildir_path' => 'hosting/1/phase3-mail.test/info', + 'credentials_issued_at' => now(), + 'incoming_token' => 'token-free-1', + 'inbound_enabled' => true, + 'outbound_enabled' => true, + 'outbound_provider' => 'smtp', + 'is_paid' => false, + ]); + + $this->actingAs($user) + ->postJson('/api/email/create', [ + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'local_part' => 'sales', + 'display_name' => 'Sales', + 'password' => 'password123', + ]) + ->assertCreated() + ->assertJsonPath('data.mailbox.is_paid', true) + ->assertJsonPath('data.usage.extra_count', 1) + ->assertJsonPath('data.invoice.total_amount', '5.00'); + + $this->assertDatabaseHas('mailboxes', [ + 'hosting_account_id' => $account->id, + 'address' => 'sales@phase3-mail.test', + 'is_paid' => true, + ]); + + $this->assertDatabaseHas('hosting_billing_invoices', [ + 'hosting_account_id' => $account->id, + 'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON, + 'status' => HostingBillingInvoice::STATUS_PENDING, + 'total_amount' => 5, + ]); + } + + public function test_mailbox_delete_requires_account_ownership(): void + { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $product = $this->makeProduct([ + 'name' => 'Starter Delete', + 'slug' => 'starter-delete-phase3', + 'max_email_accounts' => 1, + ]); + $account = $this->makeAccount($owner, $product); + $domain = $this->makeDomain($owner, $account, 'delete-mail.test'); + + $mailbox = Mailbox::create([ + 'user_id' => $owner->id, + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'display_name' => 'Owner Mailbox', + 'local_part' => 'admin', + 'address' => 'admin@delete-mail.test', + 'password_hash' => Hash::make('password123'), + 'quota_mb' => 2048, + 'status' => 'active', + 'maildir_path' => 'hosting/1/delete-mail.test/admin', + 'credentials_issued_at' => now(), + 'incoming_token' => 'token-delete-1', + 'inbound_enabled' => true, + 'outbound_enabled' => true, + 'outbound_provider' => 'smtp', + 'is_paid' => false, + ]); + + $this->actingAs($otherUser) + ->deleteJson('/api/email/delete', [ + 'mailbox_id' => $mailbox->id, + ]) + ->assertForbidden(); + } + + private function makeProduct(array $overrides = []): HostingProduct + { + return HostingProduct::create(array_merge([ + 'name' => 'Shared Phase 3', + 'slug' => 'shared-phase-3-' . str()->random(8), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'price_quarterly' => 30, + 'price_yearly' => 120, + 'price_biennial' => 240, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 5, + 'is_active' => true, + 'is_visible' => true, + ], $overrides)); + } + + private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount + { + return HostingAccount::unguarded(function () use ($user, $product, $overrides) { + return HostingAccount::create(array_merge([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'acct' . random_int(1000, 9999), + 'primary_domain' => 'phase3.example.test', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + 'allocated_disk_gb' => (int) ($product->disk_gb ?? 0), + 'php_version' => '8.4', + 'cpu_limit_percent' => 50, + 'memory_limit_mb' => 512, + 'process_limit' => 10, + 'io_limit_mb' => 5, + 'inode_limit' => 50000, + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'resource_limits' => [ + 'disk_gb' => (int) ($product->disk_gb ?? 0), + 'max_domains' => $product->max_domains, + 'max_databases' => $product->max_databases, + 'max_email_accounts' => $product->max_email_accounts, + ], + ], $overrides)); + }); + } + + private function makeDomain(User $user, HostingAccount $account, string $host): Domain + { + return Domain::create([ + 'user_id' => $user->id, + 'website_id' => null, + 'hosting_account_id' => $account->id, + 'host' => $host, + 'type' => 'custom', + 'source' => 'hosting', + 'status' => 'verified', + 'onboarding_mode' => Domain::MODE_NS_AUTO, + 'onboarding_state' => Domain::STATE_ACTIVE, + 'dns_mode' => 'managed', + 'verified_at' => now(), + 'active_at' => now(), + ]); + } +} diff --git a/tests/Feature/HostingPricingDisplayTest.php b/tests/Feature/HostingPricingDisplayTest.php new file mode 100644 index 0000000..9ad6f88 --- /dev/null +++ b/tests/Feature/HostingPricingDisplayTest.php @@ -0,0 +1,779 @@ +shouldReceive('getAvailableImages')->andReturn([ + [ + 'id' => 'ubuntu-24-image', + 'name' => 'Ubuntu 24.04', + 'description' => 'Ubuntu', + 'os_type' => 'linux', + 'standard_image' => true, + ], + [ + 'id' => 'ubuntu-image', + 'name' => 'Ubuntu 22.04', + 'description' => 'Ubuntu', + 'os_type' => 'linux', + 'standard_image' => true, + ], + [ + 'id' => 'win-image', + 'name' => 'Windows Server 2022', + 'description' => 'Windows', + 'os_type' => 'windows', + 'standard_image' => true, + ], + ]); + $provider->shouldReceive('getAvailableProducts')->andReturn([ + ['id' => 'V91', 'name' => 'Cloud VPS 10 NVMe', 'cpu' => 3, 'ram_gb' => 8, 'disk_gb' => 75, 'price_monthly' => 4.99], + ['id' => 'V45', 'name' => 'VPS S SSD', 'cpu' => 4, 'ram_gb' => 8, 'disk_gb' => 200, 'price_monthly' => 6.99], + ]); + + $this->app->instance(ContaboProvider::class, $provider); + } + + public function test_vps_page_renders_dynamic_ghs_price_instead_of_stored_zero_value(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-test', + 'description' => 'Dynamic VPS pricing test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $request = Request::create('http://localhost/hosting/vps', 'GET'); + $request->setUserResolver(fn (): User => $user); + + $view = $this->app->make(HostingProductController::class)->vps($request); + $html = $view->render(); + + $this->assertStringContainsString('GHS 140.00', $html); + $this->assertStringNotContainsString('$0.00', $html); + } + + public function test_vps_page_lists_install_later_and_multiple_live_images(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-install-later-test', + 'description' => 'Dynamic VPS pricing test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $request = Request::create('http://localhost/hosting/vps', 'GET'); + $request->setUserResolver(fn (): User => $user); + + $view = $this->app->make(HostingProductController::class)->vps($request); + $html = $view->render(); + + $this->assertStringContainsString('Install Later', $html); + $this->assertStringContainsString('Ubuntu 24.04', $html); + $this->assertStringContainsString('Ubuntu 22.04', $html); + } + + public function test_server_orders_use_display_currency_for_dynamic_products(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-order-test', + 'description' => 'Dynamic VPS order test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [ + 'product_id' => $product->id, + 'server_name' => 'pricing-test-server', + 'billing_cycle' => 'monthly', + 'region' => 'EU', + 'image' => 'ubuntu-image', + 'license' => 'none', + 'application' => 'none', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + 'server_password' => 'Secure123!!', + 'server_password_confirmation' => 'Secure123!!', + ]); + + $response->assertOk(); + + $order = CustomerHostingOrder::query()->firstOrFail(); + + $this->assertSame('GHS', $order->currency); + $this->assertSame('140.00', number_format((float) $order->amount_paid, 2, '.', '')); + } + + public function test_server_orders_persist_selected_options_password_and_semiannual_term_pricing(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-semiannual-test', + 'description' => 'Dynamic VPS order test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [ + 'product_id' => $product->id, + 'server_name' => 'semiannual-configured-server', + 'billing_cycle' => 'semiannual', + 'region' => 'EU', + 'image' => 'win-image', + 'license' => 'plesk_windows', + 'application' => 'none', + 'default_user' => 'administrator', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'eu_250', + 'server_password' => 'Abc123!!', + 'server_password_confirmation' => 'Abc123!!', + ]); + + $response->assertOk(); + + $order = CustomerHostingOrder::query()->firstOrFail(); + + $this->assertSame('semiannual', $order->billing_cycle); + $this->assertSame('4985.36', number_format((float) $order->amount_paid, 2, '.', '')); + $this->assertSame('win-image', data_get($order->meta, 'server_order.selection.image')); + $this->assertSame('administrator', data_get($order->meta, 'server_order.selection.default_user')); + $this->assertSame( + ['250 GB Object Storage (EU)'], + data_get($order->meta, 'server_order.quote.manual_follow_up') + ); + $this->assertSame('Abc123!!', decrypt((string) data_get($order->meta, 'server_order.server_password_encrypted'))); + $this->assertSame('Windows Server 2022', data_get($order->meta, 'server_order.selection_summary.0.value')); + } + + public function test_server_orders_allow_install_later_with_remote_login_only(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-install-later-order-test', + 'description' => 'Dynamic VPS order test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $response = $this->actingAs($user)->postJson(route('hosting.servers.store'), [ + 'product_id' => $product->id, + 'server_name' => 'install-later-server', + 'billing_cycle' => 'monthly', + 'region' => 'EU', + 'image' => '__install_later__', + 'license' => 'none', + 'application' => 'none', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + 'server_password' => 'Secure123!!', + 'server_password_confirmation' => 'Secure123!!', + ]); + + $response->assertOk(); + + $order = CustomerHostingOrder::query()->firstOrFail(); + + $this->assertSame('__install_later__', data_get($order->meta, 'server_order.selection.image')); + $this->assertSame('none', data_get($order->meta, 'server_order.selection.license')); + $this->assertSame('none', data_get($order->meta, 'server_order.selection.application')); + $this->assertSame('140.00', number_format((float) $order->amount_paid, 2, '.', '')); + } + + public function test_vps_10_monthly_orders_include_one_time_setup_fee_but_yearly_orders_do_not(): void + { + $user = User::factory()->create(); + $this->mockContaboImages(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $product = HostingProduct::create([ + 'name' => 'VPS 10', + 'slug' => 'vps-10-setup-fee-test', + 'description' => 'Dynamic VPS setup fee test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'GHS', + 'cpu_cores' => 3, + 'ram_mb' => 8192, + 'disk_gb' => 75, + 'contabo_product_id' => 'V91', + 'is_active' => true, + 'is_visible' => true, + ]); + + $this->actingAs($user)->postJson(route('hosting.servers.store'), [ + 'product_id' => $product->id, + 'server_name' => 'monthly-setup-fee-server', + 'billing_cycle' => 'monthly', + 'region' => 'EU', + 'image' => '__install_later__', + 'license' => 'none', + 'application' => 'none', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + 'server_password' => 'Secure123!!', + 'server_password_confirmation' => 'Secure123!!', + ])->assertOk(); + + $monthlyOrder = CustomerHostingOrder::query()->latest('id')->firstOrFail(); + + $this->assertSame('200.00', number_format((float) $monthlyOrder->amount_paid, 2, '.', '')); + $this->assertSame('100.00', number_format((float) collect(data_get($monthlyOrder->meta, 'server_order.quote.line_items', []))->firstWhere('code', 'setup_fee')['amount'], 2, '.', '')); + + $this->actingAs($user)->postJson(route('hosting.servers.store'), [ + 'product_id' => $product->id, + 'server_name' => 'yearly-no-setup-fee-server', + 'billing_cycle' => 'yearly', + 'region' => 'EU', + 'image' => '__install_later__', + 'license' => 'none', + 'application' => 'none', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + 'server_password' => 'Secure123!!', + 'server_password_confirmation' => 'Secure123!!', + ])->assertOk(); + + $yearlyOrder = CustomerHostingOrder::query()->latest('id')->firstOrFail(); + + $this->assertSame('960.00', number_format((float) $yearlyOrder->amount_paid, 2, '.', '')); + $this->assertNull(collect(data_get($yearlyOrder->meta, 'server_order.quote.line_items', []))->firstWhere('code', 'setup_fee')); + } + + public function test_provisioning_job_uses_selected_server_options_for_contabo(): void + { + $user = User::factory()->create(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $provider = \Mockery::mock(ContaboProvider::class); + $provider->shouldReceive('getAvailableImages')->once()->andReturn([ + [ + 'id' => 'win-image', + 'name' => 'Windows Server 2022', + 'description' => 'Windows', + 'os_type' => 'windows', + 'standard_image' => true, + ], + ]); + $provider->shouldReceive('createInstance') + ->once() + ->withArgs(function (array $config): bool { + $this->assertSame('V45', $config['product_id']); + $this->assertSame('win-image', $config['image_id']); + $this->assertSame('US-west', $config['region']); + $this->assertSame('administrator', $config['default_user']); + $this->assertSame('PleskHost', $config['license']); + $this->assertSame(6, $config['period']); + $this->assertSame('Abc123!!', $config['root_password']); + $this->assertArrayHasKey('additionalIps', $config['add_ons']); + $this->assertArrayHasKey('privateNetworking', $config['add_ons']); + + return true; + }) + ->andReturn([ + 'instance_id' => 'instance-123', + 'ip_address' => '203.0.113.10', + ]); + + $this->app->instance(ContaboProvider::class, $provider); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-provisioning-test', + 'description' => 'Dynamic VPS provisioning test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'contabo_region' => 'EU', + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'job-provisioned-server', + 'billing_cycle' => 'semiannual', + 'amount_paid' => 0, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_APPROVED, + 'meta' => [ + 'region' => 'US-west', + 'server_order' => [ + 'selection' => [ + 'image' => 'win-image', + 'region' => 'US-west', + 'license' => 'plesk_host', + 'application' => 'none', + 'default_user' => 'administrator', + 'additional_ip' => 'one_extra', + 'private_networking' => 'enabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + ], + 'server_password_encrypted' => encrypt('Abc123!!'), + ], + ], + ]); + + $capacityService = \Mockery::mock(NodeCapacityService::class); + $sharedProvider = \Mockery::mock(SharedNodeProvider::class); + $serverOrders = $this->app->make(ServerOrderService::class); + $bootstrap = \Mockery::mock(ServerAgentBootstrapService::class); + $bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']); + $installer = \Mockery::mock(ServerAgentInstallerService::class); + $installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing( + fn ($freshOrder, array $config, array $bootstrapConfig): array => $config + ); + + (new ProvisionHostingOrderJob($order))->handle( + $capacityService, + app(HostingResourcePolicyService::class), + $sharedProvider, + $provider, + $serverOrders, + $bootstrap, + $installer, + ); + + $order->refresh(); + + $this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status); + $this->assertSame('203.0.113.10', $order->server_ip); + $this->assertSame('administrator', $order->server_username); + $this->assertSame('instance-123', data_get($order->meta, 'contabo_instance_id')); + $this->assertDatabaseHas('provisioning_queue', [ + 'customer_hosting_order_id' => $order->id, + 'status' => ProvisioningQueueItem::STATUS_COMPLETED, + ]); + } + + public function test_ladill_control_panel_selection_sets_internal_panel_url_after_provisioning(): void + { + $user = User::factory()->create(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $provider = \Mockery::mock(ContaboProvider::class); + $provider->shouldReceive('getAvailableImages')->once()->andReturn([ + [ + 'id' => 'ubuntu-image', + 'name' => 'Ubuntu 22.04', + 'description' => 'Ubuntu', + 'os_type' => 'linux', + 'standard_image' => true, + ], + ]); + $provider->shouldReceive('createInstance') + ->once() + ->withArgs(function (array $config): bool { + $this->assertNull($config['license']); + $this->assertSame('ladill', $config['panel']); + + return true; + }) + ->andReturn([ + 'instance_id' => 'instance-panel-123', + 'ip_address' => '203.0.113.20', + 'status' => 'running', + 'region' => 'EU', + 'datacenter' => 'MUC1', + 'image' => 'ubuntu-image', + ]); + + $this->app->instance(ContaboProvider::class, $provider); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-ladill-panel-test', + 'description' => 'Dynamic VPS provisioning test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'contabo_region' => 'EU', + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'ladill-panel-server', + 'billing_cycle' => 'monthly', + 'amount_paid' => 0, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_APPROVED, + 'meta' => [ + 'region' => 'EU', + 'server_order' => [ + 'selection' => [ + 'image' => 'ubuntu-image', + 'region' => 'EU', + 'license' => 'ladill_panel', + 'application' => 'none', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + ], + 'selection_summary' => [ + ['label' => 'License', 'value' => 'Ladill Control Panel'], + ], + 'server_password_encrypted' => encrypt('Root123!!'), + ], + ], + ]); + + $capacityService = \Mockery::mock(NodeCapacityService::class); + $sharedProvider = \Mockery::mock(SharedNodeProvider::class); + $serverOrders = $this->app->make(ServerOrderService::class); + $bootstrap = \Mockery::mock(ServerAgentBootstrapService::class); + $bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']); + $installer = \Mockery::mock(ServerAgentInstallerService::class); + $installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing( + fn ($freshOrder, array $config, array $bootstrapConfig): array => $config + ); + + (new ProvisionHostingOrderJob($order))->handle( + $capacityService, + app(HostingResourcePolicyService::class), + $sharedProvider, + $provider, + $serverOrders, + $bootstrap, + $installer, + ); + + $order->refresh(); + + $this->assertSame(route('hosting.server-panel.show', $order), $order->control_panel_url); + $this->assertSame('ladill', data_get($order->meta, 'server_panel.panel')); + $this->assertSame('instance-panel-123', data_get($order->meta, 'contabo_instance_id')); + } + + public function test_ladill_server_panel_page_and_power_action_work_for_selected_orders(): void + { + $user = User::factory()->create(); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-panel-route-test', + 'description' => 'Dynamic VPS provisioning test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'panel-route-server', + 'billing_cycle' => 'monthly', + 'amount_paid' => 0, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + 'server_ip' => '203.0.113.50', + 'server_username' => 'root', + 'control_panel_url' => 'http://localhost/hosting/orders/1/panel', + 'meta' => [ + 'contabo_instance_id' => 'instance-route-123', + 'server_order' => [ + 'selection' => [ + 'license' => 'ladill_panel', + 'application' => 'none', + ], + 'selection_summary' => [ + ['label' => 'License', 'value' => 'Ladill Control Panel'], + ], + ], + 'server_panel' => [ + 'panel' => 'ladill', + 'power_status' => 'on', + 'instance_status' => 'running', + ], + ], + ]); + + $provider = \Mockery::mock(ContaboProvider::class); + $provider->shouldReceive('startInstance')->once()->with('instance-route-123')->andReturn(true); + $this->app->instance(ContaboProvider::class, $provider); + + $this->actingAs($user) + ->get(route('hosting.server-panel.show', $order)) + ->assertOk() + ->assertSee('Ladill Control Panel') + ->assertSee('panel-route-server'); + + $this->actingAs($user) + ->post(route('hosting.server-panel.power', [$order, 'start'])) + ->assertRedirect(); + } + + public function test_provisioning_uses_cloud_init_for_webmin_profile(): void + { + $user = User::factory()->create(); + + $this->mock(ExchangeRateService::class, function ($mock): void { + $mock->shouldReceive('getUsdToGhs')->andReturn(15.50); + }); + + $provider = \Mockery::mock(ContaboProvider::class); + $provider->shouldReceive('getAvailableImages')->once()->andReturn([ + [ + 'id' => 'ubuntu-image', + 'name' => 'Ubuntu 22.04', + 'description' => 'Ubuntu', + 'os_type' => 'linux', + 'standard_image' => true, + ], + ]); + $provider->shouldReceive('generateCloudInit')->once()->andReturn('#cloud-config webmin'); + $provider->shouldReceive('createInstance') + ->once() + ->withArgs(function (array $config): bool { + $this->assertSame('ubuntu-image', $config['image_id']); + $this->assertSame('#cloud-config webmin', $config['user_data']); + $this->assertNull($config['license']); + $this->assertNull($config['application_id']); + + return true; + }) + ->andReturn([ + 'instance_id' => 'instance-webmin-123', + 'ip_address' => '203.0.113.30', + ]); + + $this->app->instance(ContaboProvider::class, $provider); + + $product = HostingProduct::create([ + 'name' => 'VPS S', + 'slug' => 'vps-s-webmin-test', + 'description' => 'Dynamic VPS provisioning test', + 'category' => 'vps', + 'type' => 'vps', + 'price_monthly' => 0, + 'price_quarterly' => 0, + 'price_yearly' => 0, + 'currency' => 'USD', + 'cpu_cores' => 4, + 'ram_mb' => 8192, + 'disk_gb' => 200, + 'contabo_product_id' => 'V45', + 'contabo_region' => 'EU', + 'is_active' => true, + 'is_visible' => true, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'webmin-server', + 'billing_cycle' => 'monthly', + 'amount_paid' => 0, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_APPROVED, + 'meta' => [ + 'region' => 'EU', + 'server_order' => [ + 'selection' => [ + 'image' => 'ubuntu-image', + 'region' => 'EU', + 'license' => 'none', + 'application' => 'webmin', + 'default_user' => 'root', + 'additional_ip' => 'none', + 'private_networking' => 'disabled', + 'storage_type' => 'included', + 'object_storage' => 'none', + ], + 'server_password_encrypted' => encrypt('Root123!!'), + ], + ], + ]); + + $capacityService = \Mockery::mock(NodeCapacityService::class); + $sharedProvider = \Mockery::mock(SharedNodeProvider::class); + $serverOrders = $this->app->make(ServerOrderService::class); + $bootstrap = \Mockery::mock(ServerAgentBootstrapService::class); + $bootstrap->shouldReceive('ensureBootstrap')->andReturn(['token' => 'bootstrap-token']); + $installer = \Mockery::mock(ServerAgentInstallerService::class); + $installer->shouldReceive('augmentProvisioningConfig')->andReturnUsing( + fn ($freshOrder, array $config, array $bootstrapConfig): array => $config + ); + + (new ProvisionHostingOrderJob($order))->handle( + $capacityService, + app(HostingResourcePolicyService::class), + $sharedProvider, + $provider, + $serverOrders, + $bootstrap, + $installer, + ); + + $this->assertDatabaseHas('provisioning_queue', [ + 'customer_hosting_order_id' => $order->id, + 'status' => ProvisioningQueueItem::STATUS_COMPLETED, + ]); + } +} diff --git a/tests/Feature/HostingResourcePolicyServiceTest.php b/tests/Feature/HostingResourcePolicyServiceTest.php new file mode 100644 index 0000000..0113ee7 --- /dev/null +++ b/tests/Feature/HostingResourcePolicyServiceTest.php @@ -0,0 +1,343 @@ +makeAccount([ + 'allocated_disk_gb' => 10, + 'disk_used_bytes' => (int) (9.1 * 1073741824), + 'inode_limit' => 50000, + 'inode_count' => 1000, + 'cpu_limit_percent' => 50, + 'cpu_usage_percent' => 10, + 'memory_limit_mb' => 512, + 'memory_used_mb' => 128, + 'process_limit' => 10, + 'process_count' => 2, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('applyAccountResourceProfile')->never(); + $provider->shouldReceive('suspendAccount')->never(); + $this->app->instance(SharedNodeProvider::class, $provider); + + app(HostingResourcePolicyService::class)->process($account); + + $account->refresh(); + + $this->assertTrue($account->is_flagged); + $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); + $this->assertDatabaseHas('hosting_account_alerts', [ + 'hosting_account_id' => $account->id, + 'alert_type' => HostingAccountAlert::TYPE_WARNING, + 'is_resolved' => false, + ]); + + Notification::assertSentTo($account->user, HostingResourceWarningNotification::class); + } + + public function test_it_throttles_an_account_when_cpu_limit_is_exceeded(): void + { + Notification::fake(); + + $account = $this->makeAccount([ + 'cpu_limit_percent' => 50, + 'cpu_usage_percent' => 90, + 'memory_limit_mb' => 512, + 'memory_used_mb' => 128, + 'process_limit' => 10, + 'process_count' => 3, + 'inode_limit' => 50000, + 'inode_count' => 1000, + 'throttled_at' => now()->subHours(25), + 'cpu_breach_streak' => 11, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( + fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === true + )->andReturn(true); + $provider->shouldReceive('suspendAccount')->never(); + $this->app->instance(SharedNodeProvider::class, $provider); + + app(HostingResourcePolicyService::class)->process($account); + + $account->refresh(); + + $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_THROTTLED, $account->resource_status); + $this->assertNotNull($account->throttled_at); + $this->assertDatabaseHas('hosting_account_alerts', [ + 'hosting_account_id' => $account->id, + 'alert_type' => HostingAccountAlert::TYPE_THROTTLED, + 'is_resolved' => false, + ]); + } + + public function test_it_suspends_a_throttled_account_when_abuse_continues(): void + { + Notification::fake(); + + $account = $this->makeAccount([ + 'status' => HostingAccount::STATUS_ACTIVE, + 'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED, + 'cpu_limit_percent' => 50, + 'cpu_usage_percent' => 10, + 'memory_limit_mb' => 512, + 'memory_used_mb' => 1024, + 'process_limit' => 10, + 'process_count' => 3, + 'inode_limit' => 50000, + 'inode_count' => 1000, + 'throttled_at' => now()->subHours(25), + 'memory_breach_streak' => 11, + ]); + + $order = CustomerHostingOrder::create([ + 'user_id' => $account->user_id, + 'hosting_product_id' => $account->hosting_product_id, + 'hosting_account_id' => $account->id, + 'domain_name' => $account->primary_domain ?: 'example.test', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 10, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('applyAccountResourceProfile')->never(); + $provider->shouldReceive('suspendAccount')->once()->withArgs( + fn (HostingAccount $candidate, string $reason): bool => $candidate->id === $account->id && str_contains($reason, 'Memory usage') + )->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + app(HostingResourcePolicyService::class)->process($account); + + $account->refresh(); + $order->refresh(); + + $this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status); + $this->assertSame(CustomerHostingOrder::STATUS_SUSPENDED, $order->status); + $this->assertDatabaseHas('hosting_account_alerts', [ + 'hosting_account_id' => $account->id, + 'alert_type' => HostingAccountAlert::TYPE_SUSPENDED, + 'is_resolved' => false, + ]); + Notification::assertNothingSent(); + } + + public function test_it_unsuspends_an_automatically_suspended_account_when_usage_returns_to_normal(): void + { + Notification::fake(); + + $account = $this->makeAccount([ + 'status' => HostingAccount::STATUS_SUSPENDED, + 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, + 'cpu_limit_percent' => 50, + 'cpu_usage_percent' => 0, + 'memory_limit_mb' => 512, + 'memory_used_mb' => 128, + 'process_limit' => 10, + 'process_count' => 2, + 'inode_limit' => 50000, + 'inode_count' => 1000, + 'suspension_reason' => 'CPU usage reached 110.6% against a 50% limit.', + 'suspended_at' => now()->subMinutes(10), + 'metadata' => [ + 'suspension_origin' => 'automatic', + ], + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('unsuspendAccount')->once()->withArgs( + fn (HostingAccount $candidate): bool => $candidate->id === $account->id + )->andReturn(true); + $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( + fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false + )->andReturn(true); + $provider->shouldReceive('suspendAccount')->never(); + $this->app->instance(SharedNodeProvider::class, $provider); + + app(HostingResourcePolicyService::class)->process($account); + + $account->refresh(); + + $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); + $this->assertNull($account->suspended_at); + $this->assertNull($account->suspension_reason); + $this->assertFalse($account->uploads_restricted); + } + + public function test_it_does_not_unsuspend_a_manually_suspended_account_when_usage_is_normal(): void + { + Notification::fake(); + + $account = $this->makeAccount([ + 'status' => HostingAccount::STATUS_SUSPENDED, + 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, + 'cpu_limit_percent' => 50, + 'cpu_usage_percent' => 0, + 'memory_limit_mb' => 512, + 'memory_used_mb' => 128, + 'process_limit' => 10, + 'process_count' => 2, + 'inode_limit' => 50000, + 'inode_count' => 1000, + 'suspension_reason' => 'Manual suspension.', + 'suspended_at' => now()->subMinutes(10), + 'metadata' => [ + 'suspension_origin' => 'manual', + ], + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('applyAccountResourceProfile')->never(); + $provider->shouldReceive('unsuspendAccount')->never(); + $provider->shouldReceive('suspendAccount')->never(); + $this->app->instance(SharedNodeProvider::class, $provider); + + app(HostingResourcePolicyService::class)->process($account); + + $account->refresh(); + + $this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status); + $this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status); + $this->assertNotNull($account->suspended_at); + $this->assertSame('Manual suspension.', $account->suspension_reason); + } + + public function test_it_applies_slug_specific_inode_limits_for_single_domain_products(): void + { + $starter = HostingProduct::query()->updateOrCreate([ + 'slug' => 'starter-plan', + ], [ + 'name' => 'Starter Plan', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 1, + 'currency' => 'GHS', + 'disk_gb' => 5, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $basic = HostingProduct::query()->updateOrCreate([ + 'slug' => 'basic-plan', + ], [ + 'name' => 'Basic Plan', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 25, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $plus = HostingProduct::query()->updateOrCreate([ + 'slug' => 'plus-plan', + ], [ + 'name' => 'Plus Plan', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 35, + 'currency' => 'GHS', + 'disk_gb' => 20, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $growth = HostingProduct::query()->updateOrCreate([ + 'slug' => 'growth-plan', + ], [ + 'name' => 'Growth Plan', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_MULTI_DOMAIN, + 'price_monthly' => 55, + 'currency' => 'GHS', + 'disk_gb' => 30, + 'max_domains' => 5, + 'is_active' => true, + 'is_visible' => true, + ]); + + $pro = HostingProduct::query()->updateOrCreate([ + 'slug' => 'pro-plan', + ], [ + 'name' => 'Pro Plan', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_MULTI_DOMAIN, + 'price_monthly' => 175, + 'currency' => 'GHS', + 'disk_gb' => 150, + 'max_domains' => -1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $service = app(HostingResourcePolicyService::class); + + $this->assertSame(225000, $service->defaultLimitsForProduct($starter)['inode_limit']); + $this->assertSame(450000, $service->defaultLimitsForProduct($basic)['inode_limit']); + $this->assertSame(450000, $service->defaultLimitsForProduct($plus)['inode_limit']); + $this->assertSame(750000, $service->defaultLimitsForProduct($growth)['inode_limit']); + $this->assertSame(1200000, $service->defaultLimitsForProduct($pro)['inode_limit']); + } + + private function makeAccount(array $overrides = []): HostingAccount + { + $user = User::factory()->create(); + $product = HostingProduct::create([ + 'name' => 'Resource Test Hosting', + 'slug' => 'resource-test-hosting-' . str()->random(6), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + return HostingAccount::create(array_merge([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'acct' . random_int(1000, 9999), + 'primary_domain' => 'resource-test.example', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + 'allocated_disk_gb' => 10, + 'cpu_limit_percent' => 50, + 'memory_limit_mb' => 512, + 'process_limit' => 10, + 'io_limit_mb' => 5, + 'inode_limit' => 50000, + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + ], $overrides)); + } +} diff --git a/tests/Feature/HostingTeamAccessTest.php b/tests/Feature/HostingTeamAccessTest.php new file mode 100644 index 0000000..0166545 --- /dev/null +++ b/tests/Feature/HostingTeamAccessTest.php @@ -0,0 +1,438 @@ +create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + $this->actingAs($owner) + ->get(route('user.team.index')) + ->assertOk() + ->assertSee('Add Developer') + ->assertSee($account->primary_domain); + + $response = $this->actingAs($owner)->post(route('user.team.store'), [ + 'email' => $developer->email, + 'hosting_account_ids' => [$account->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success', 'Developer access granted and notification sent.'); + + $this->assertDatabaseHas('hosting_account_members', [ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + ]); + + Notification::assertSentTo($developer, HostingDeveloperAddedNotification::class); + } + + public function test_owner_can_invite_new_developer_account_and_send_setup_notification(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + $response = $this->actingAs($owner)->post(route('user.team.store'), [ + 'name' => 'External Developer', + 'email' => 'external-dev@example.com', + 'hosting_account_ids' => [$account->id], + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success', 'Developer added and invited by email.'); + + $developer = User::query()->where('email', 'external-dev@example.com')->first(); + + $this->assertNotNull($developer); + $this->assertDatabaseHas('hosting_account_members', [ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + ]); + + Notification::assertSentTo($developer, HostingDeveloperAddedNotification::class); + } + + public function test_owner_can_invite_developer_with_ssh_key_and_install_shell_access(): void + { + Notification::fake(); + + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + $sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com'; + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('installTeamMemberSshKey') + ->once() + ->withArgs(function (HostingAccount $passedAccount, string $marker, string $publicKey) use ($account, $sshKey) { + return $passedAccount->is($account) + && str_starts_with($marker, 'ladill-team-member-') + && $publicKey === $sshKey; + }); + $this->app->instance(SharedNodeProvider::class, $provider); + + $response = $this->actingAs($owner)->post(route('user.team.store'), [ + 'email' => $developer->email, + 'hosting_account_ids' => [$account->id], + 'ssh_public_key' => $sshKey, + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $membership = HostingAccountMember::query() + ->where('hosting_account_id', $account->id) + ->where('user_id', $developer->id) + ->first(); + + $this->assertNotNull($membership); + $this->assertSame($sshKey, $membership->ssh_public_key); + $this->assertNotNull($membership->ssh_key_installed_at); + } + + public function test_developer_can_open_assigned_hosting_views_but_not_owner_only_actions(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + $this->actingAs($developer) + ->get(route('hosting.single-domain')) + ->assertOk() + ->assertSee('Developer access') + ->assertSee('Open Panel'); + + $this->actingAs($developer) + ->get(route('hosting.panel.domains', $account)) + ->assertOk() + ->assertSee('Add Domain'); + + $this->actingAs($developer) + ->get(route('hosting.panel.settings', $account)) + ->assertOk() + ->assertSee('SFTP Key Access') + ->assertDontSee('SFTP Password'); + + $this->actingAs($developer) + ->post(route('hosting.panel.settings.password', $account), [ + 'password' => 'new-password-123', + 'password_confirmation' => 'new-password-123', + ]) + ->assertForbidden(); + + $this->actingAs($developer) + ->get(route('hosting.accounts.show', $account)) + ->assertForbidden(); + } + + public function test_developer_sees_assigned_hosting_domains_in_all_domains(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + $domain = Domain::create([ + 'user_id' => $owner->id, + 'hosting_account_id' => $account->id, + 'host' => 'developer-visible.example.com', + 'type' => 'custom', + 'source' => 'hosting', + 'status' => 'verified', + 'onboarding_state' => Domain::STATE_ACTIVE, + ]); + + $this->actingAs($developer) + ->get(route('user.domains.index')) + ->assertOk() + ->assertSee($domain->host); + + $this->actingAs($developer) + ->get(route('user.domains.show', $domain)) + ->assertOk() + ->assertSee($domain->host); + } + + public function test_developer_dashboard_counts_assigned_hosting_domains(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + Domain::create([ + 'user_id' => $owner->id, + 'hosting_account_id' => $account->id, + 'host' => 'developer-dashboard.example.com', + 'type' => 'custom', + 'source' => 'hosting', + 'status' => 'verified', + 'onboarding_state' => Domain::STATE_ACTIVE, + ]); + + $this->actingAs($developer) + ->get(route('dashboard')) + ->assertOk() + ->assertSeeTextInOrder(['Domains', '1', '0 pending', 'Hosting']); + } + + public function test_developer_only_user_cannot_see_or_open_wallet_and_billing(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + $this->actingAs($developer) + ->get(route('dashboard')) + ->assertOk() + ->assertDontSeeText('Wallet') + ->assertDontSeeText('Billing'); + + $this->actingAs($developer) + ->get(route('user.wallet.index')) + ->assertForbidden(); + + $this->actingAs($developer) + ->get(route('user.billing')) + ->assertForbidden(); + } + + public function test_developer_cannot_remove_hosting_panel_domain_but_owner_can(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + + $site = HostedSite::create([ + 'hosting_account_id' => $account->id, + 'domain' => 'owner-removable.example.com', + 'document_root' => '/home/'.$account->username.'/public_html/owner-removable.example.com', + 'type' => 'addon', + 'status' => 'active', + 'ssl_enabled' => false, + ]); + + $this->actingAs($developer) + ->delete(route('hosting.panel.domains.remove', [$account, $site])) + ->assertForbidden(); + + $this->assertDatabaseHas('hosted_sites', [ + 'id' => $site->id, + 'deleted_at' => null, + ]); + + $runtime = \Mockery::mock(PanelRuntimeInterface::class); + $runtime->shouldReceive('removeSite') + ->once() + ->withArgs(fn (HostedSite $passedSite): bool => $passedSite->is($site)) + ->andReturn(true); + + $resolver = \Mockery::mock(PanelRuntimeResolver::class); + $resolver->shouldReceive('forSubject') + ->once() + ->withArgs(fn (HostingAccount $passedAccount): bool => $passedAccount->is($account)) + ->andReturn($runtime); + + $session = $this->app['session.store']; + $session->start(); + + $request = \Illuminate\Http\Request::create(route('hosting.panel.domains.remove', [$account, $site]), 'DELETE', [], [], [], [ + 'HTTP_REFERER' => route('hosting.panel.domains', $account), + ]); + $request->setUserResolver(fn (): User => $owner); + $request->setLaravelSession($session); + + $response = (new \App\Http\Controllers\Hosting\HostingPanelController($resolver)) + ->removeDomain($request, $account, $site); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('Domain removed successfully.', $session->get('success')); + + $this->assertSoftDeleted('hosted_sites', [ + 'id' => $site->id, + ]); + } + + public function test_owner_can_revoke_developer_and_access_is_removed_immediately(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + + $membership = HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + 'ssh_public_key' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com', + 'ssh_key_installed_at' => now(), + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('removeTeamMemberSshKey') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $marker) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker()); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($owner) + ->delete(route('user.team.destroy', $developer)) + ->assertRedirect() + ->assertSessionHas('success', 'Developer access removed.'); + + $this->assertDatabaseMissing('hosting_account_members', [ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + ]); + + $this->actingAs($developer) + ->get(route('hosting.panel.domains', $account)) + ->assertForbidden(); + } + + public function test_developer_can_save_and_remove_their_own_ssh_key(): void + { + $owner = User::factory()->create(); + $developer = User::factory()->create(); + $account = $this->createHostingAccount($owner); + $membership = HostingAccountMember::create([ + 'hosting_account_id' => $account->id, + 'user_id' => $developer->id, + 'invited_by_user_id' => $owner->id, + 'role' => HostingAccountMember::ROLE_DEVELOPER, + 'invited_at' => now(), + ]); + $sshKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBlY0P2hT9m5m3GAnExampleKeyData123456789 developer@example.com'; + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('installTeamMemberSshKey') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $marker, string $publicKey) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker() && $publicKey === $sshKey); + $provider->shouldReceive('removeTeamMemberSshKey') + ->once() + ->withArgs(fn (HostingAccount $passedAccount, string $marker) => (int) $passedAccount->id === (int) $account->id && $marker === $membership->sshKeyMarker()); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($developer) + ->post(route('hosting.panel.settings.ssh-key', $account), [ + 'ssh_public_key' => $sshKey, + ]) + ->assertRedirect() + ->assertSessionHas('success', 'SSH key saved. You can now connect over SFTP.'); + + $membership->refresh(); + $this->assertSame($sshKey, $membership->ssh_public_key); + $this->assertNotNull($membership->ssh_key_installed_at); + + $this->actingAs($developer) + ->delete(route('hosting.panel.settings.ssh-key.destroy', $account)) + ->assertRedirect() + ->assertSessionHas('success', 'SSH key removed. SFTP access through your team membership has been revoked.'); + + $membership->refresh(); + $this->assertNull($membership->ssh_public_key); + $this->assertNull($membership->ssh_key_installed_at); + } + + private function createHostingAccount(User $owner): HostingAccount + { + $product = HostingProduct::create([ + 'name' => 'Single Domain Hosting', + 'slug' => 'single-domain-team-test-'.$owner->id, + 'category' => 'shared', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $node = HostingNode::create([ + 'name' => 'Local Node', + 'hostname' => 'local-node-'.$owner->id, + 'ip_address' => '127.0.0.1', + 'type' => 'shared', + 'provider' => 'local', + 'status' => 'active', + 'ssh_port' => '22', + ]); + + return HostingAccount::unguarded(function () use ($owner, $product, $node) { + return HostingAccount::create([ + 'user_id' => $owner->id, + 'hosting_product_id' => $product->id, + 'hosting_node_id' => $node->id, + 'username' => 'acct'.$owner->id, + 'primary_domain' => 'team-test-'.$owner->id.'.example.com', + 'type' => 'shared', + 'status' => 'active', + 'php_version' => '8.2', + ]); + }); + } +} diff --git a/tests/Feature/HostingUpsellRecommendationTest.php b/tests/Feature/HostingUpsellRecommendationTest.php new file mode 100644 index 0000000..c83370e --- /dev/null +++ b/tests/Feature/HostingUpsellRecommendationTest.php @@ -0,0 +1,283 @@ +create(); + $starter = $this->makeProduct([ + 'name' => 'Starter Plan', + 'slug' => 'starter-upsell-api', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 1, + 'disk_gb' => 5, + 'max_domains' => 1, + 'max_email_accounts' => 5, + 'sort_order' => 1, + ]); + $basic = $this->makeProduct([ + 'name' => 'Basic Plan', + 'slug' => 'basic-upsell-api', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 15, + 'disk_gb' => 25, + 'max_domains' => 1, + 'max_email_accounts' => 10, + 'sort_order' => 2, + ]); + + $account = $this->makeAccount($user, $starter, [ + 'primary_domain' => 'starter-upsell.test', + 'disk_used_bytes' => (int) round(5 * 0.8 * 1073741824), + ]); + + $this->actingAs($user) + ->getJson('/api/upsells/recommendations') + ->assertOk() + ->assertJsonPath('data.0.type', 'plan_upgrade') + ->assertJsonPath('data.0.account_id', $account->id) + ->assertJsonPath('data.0.target_product_id', $basic->id) + ->assertJsonPath('data.0.target_product_name', 'Basic Plan'); + } + + public function test_api_returns_email_addon_recommendation_when_mailbox_usage_exceeds_allowance(): void + { + $user = User::factory()->create(); + $product = $this->makeProduct([ + 'name' => 'Growth Plan', + 'slug' => 'growth-upsell-email', + 'max_email_accounts' => 1, + ]); + $account = $this->makeAccount($user, $product, [ + 'primary_domain' => 'mailbox-upsell.test', + ]); + $domain = $this->makeDomain($user, $account, 'mailbox-upsell.test'); + + Mailbox::create([ + 'user_id' => $user->id, + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'display_name' => 'Info', + 'local_part' => 'info', + 'address' => 'info@mailbox-upsell.test', + 'password_hash' => Hash::make('password123'), + 'quota_mb' => 2048, + 'status' => 'active', + 'maildir_path' => 'hosting/1/mailbox-upsell.test/info', + 'credentials_issued_at' => now(), + 'incoming_token' => 'upsell-token-1', + 'inbound_enabled' => true, + 'outbound_enabled' => true, + 'outbound_provider' => 'smtp', + 'is_paid' => false, + ]); + + Mailbox::create([ + 'user_id' => $user->id, + 'hosting_account_id' => $account->id, + 'domain_id' => $domain->id, + 'display_name' => 'Sales', + 'local_part' => 'sales', + 'address' => 'sales@mailbox-upsell.test', + 'password_hash' => Hash::make('password123'), + 'quota_mb' => 2048, + 'status' => 'active', + 'maildir_path' => 'hosting/1/mailbox-upsell.test/sales', + 'credentials_issued_at' => now(), + 'incoming_token' => 'upsell-token-2', + 'inbound_enabled' => true, + 'outbound_enabled' => true, + 'outbound_provider' => 'smtp', + 'is_paid' => true, + ]); + + $this->actingAs($user) + ->getJson('/api/upsells/recommendations') + ->assertOk() + ->assertJsonFragment([ + 'type' => 'email_addon', + 'account_id' => $account->id, + 'suggested_quantity' => 10, + ]) + ->assertJsonFragment([ + 'estimated_amount' => 50.0, + ]); + } + + public function test_account_page_renders_recommendation_and_web_plan_change_route_applies_upgrade(): void + { + $user = User::factory()->create(); + $starter = $this->makeProduct([ + 'name' => 'Starter Plan', + 'slug' => 'starter-upsell-web', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 1, + 'disk_gb' => 5, + 'sort_order' => 1, + ]); + $basic = $this->makeProduct([ + 'name' => 'Basic Plan', + 'slug' => 'basic-upsell-web', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 15, + 'disk_gb' => 25, + 'max_email_accounts' => 10, + 'sort_order' => 2, + ]); + + $account = $this->makeAccount($user, $starter, [ + 'primary_domain' => 'upgrade-web.test', + 'disk_used_bytes' => (int) round(5 * 0.82 * 1073741824), + 'expires_at' => now()->addMonth(), + ]); + + CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $starter->id, + 'hosting_account_id' => $account->id, + 'domain_name' => $account->primary_domain, + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 1, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_ACTIVE, + 'expires_at' => now()->addMonth(), + ]); + + $provider = \Mockery::mock(SharedNodeProvider::class); + $provider->shouldReceive('syncAccountPackage')->once()->andReturn(true); + $this->app->instance(SharedNodeProvider::class, $provider); + + $this->actingAs($user) + ->get(route('hosting.accounts.show', $account)) + ->assertOk() + ->assertSeeText('Recommended Next Steps') + ->assertSeeText('Upgrade to Basic Plan'); + + $this->actingAs($user) + ->post(route('hosting.accounts.plan-change', $account), [ + 'target_product_id' => $basic->id, + ]) + ->assertRedirect(route('hosting.accounts.show', $account)); + + $account->refresh(); + + $this->assertSame($basic->id, $account->hosting_product_id); + $this->assertSame(25, $account->allocated_disk_gb); + } + + public function test_dashboard_renders_hosting_recommendations(): void + { + $user = User::factory()->create(); + $starter = $this->makeProduct([ + 'name' => 'Starter Plan', + 'slug' => 'starter-upsell-dashboard', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'disk_gb' => 5, + 'sort_order' => 1, + ]); + $this->makeProduct([ + 'name' => 'Basic Plan', + 'slug' => 'basic-upsell-dashboard', + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'disk_gb' => 25, + 'sort_order' => 2, + ]); + + $this->makeAccount($user, $starter, [ + 'primary_domain' => 'dashboard-upsell.test', + 'disk_used_bytes' => (int) round(5 * 0.75 * 1073741824), + ]); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertOk() + ->assertSeeText('Recommended Next Steps') + ->assertSeeText('Storage usage is high on dashboard-upsell.test'); + } + + private function makeProduct(array $overrides = []): HostingProduct + { + return HostingProduct::create(array_merge([ + 'name' => 'Shared Upsell', + 'slug' => 'shared-upsell-' . str()->random(8), + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 10, + 'price_quarterly' => 30, + 'price_yearly' => 120, + 'price_biennial' => 240, + 'currency' => 'GHS', + 'disk_gb' => 10, + 'max_domains' => 1, + 'max_databases' => 2, + 'max_email_accounts' => 5, + 'bandwidth_gb' => 100, + 'is_active' => true, + 'is_visible' => true, + ], $overrides)); + } + + private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount + { + return HostingAccount::unguarded(function () use ($user, $product, $overrides) { + return HostingAccount::create(array_merge([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'username' => 'acct' . random_int(1000, 9999), + 'primary_domain' => 'upsell.example.test', + 'type' => HostingAccount::TYPE_SHARED, + 'status' => HostingAccount::STATUS_ACTIVE, + 'allocated_disk_gb' => (int) ($product->disk_gb ?? 0), + 'disk_used_bytes' => 0, + 'bandwidth_used_bytes' => 0, + 'php_version' => '8.4', + 'cpu_limit_percent' => 50, + 'memory_limit_mb' => 512, + 'process_limit' => 10, + 'io_limit_mb' => 5, + 'inode_limit' => 50000, + 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, + 'resource_limits' => [ + 'disk_gb' => (int) ($product->disk_gb ?? 0), + 'bandwidth_gb' => $product->bandwidth_gb, + 'max_domains' => $product->max_domains, + 'max_databases' => $product->max_databases, + 'max_email_accounts' => $product->max_email_accounts, + ], + ], $overrides)); + }); + } + + private function makeDomain(User $user, HostingAccount $account, string $host): Domain + { + return Domain::create([ + 'user_id' => $user->id, + 'website_id' => null, + 'hosting_account_id' => $account->id, + 'host' => $host, + 'type' => 'custom', + 'source' => 'hosting', + 'status' => 'verified', + 'onboarding_mode' => Domain::MODE_NS_AUTO, + 'onboarding_state' => Domain::STATE_ACTIVE, + 'dns_mode' => 'managed', + 'verified_at' => now(), + 'active_at' => now(), + ]); + } +} diff --git a/tests/Feature/MailboxesTest.php b/tests/Feature/MailboxesTest.php new file mode 100644 index 0000000..62728cb --- /dev/null +++ b/tests/Feature/MailboxesTest.php @@ -0,0 +1,172 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + private function mailboxClient(): \Mockery\MockInterface + { + $m = Mockery::mock(MailboxClient::class); + $this->app->instance(MailboxClient::class, $m); + + return $m; + } + + private function wallet(): \Mockery\MockInterface + { + $w = Mockery::mock(WalletPaymentService::class); + $w->shouldReceive('balanceMinor')->andReturn(0)->byDefault(); + $this->app->instance(WalletPaymentService::class, $w); + + return $w; + } + + public function test_root_redirects_guest_to_sso(): void + { + $this->get('/')->assertRedirect(route('sso.connect')); + } + + public function test_mailboxes_require_login(): void + { + $this->get(route('email.mailboxes.index'))->assertRedirect(route('login')); + } + + public function test_index_lists_from_api(): void + { + $this->mailboxClient()->shouldReceive('forUser')->andReturn([ + ['id' => 1, 'address' => 'me@acme.com', 'display_name' => 'Me', 'quota_mb' => 10240, 'status' => 'active'], + ]); + + $this->actingAs($this->user())->get(route('email.mailboxes.index'))->assertOk()->assertSee('me@acme.com'); + } + + public function test_create_form_shows_free_banner_within_allowance(): void + { + config(['email.free_mailboxes' => 1]); + $this->mailboxClient()->shouldReceive('forUser')->andReturn([]); + $this->wallet(); + $dc = Mockery::mock(EmailDomainClient::class); + $dc->shouldReceive('verified')->andReturn([['id' => 7, 'domain' => 'acme.com']]); + $this->app->instance(EmailDomainClient::class, $dc); + + $this->actingAs($this->user())->get(route('email.mailboxes.create')) + ->assertOk()->assertSee('free, forever', false); + } + + public function test_store_free_1gb_mailbox_creates_without_charge(): void + { + $mc = $this->mailboxClient(); + $mc->shouldReceive('create')->once() + ->with(Mockery::any(), 7, 'sales', 'Sales', 'secret123', 1024, false, null) // 1 GB = free + ->andReturn(['id' => 9, 'address' => 'sales@acme.com']); + $w = $this->wallet(); + $w->shouldNotReceive('charge'); + + $this->actingAs($this->user())->post(route('email.mailboxes.store'), [ + 'email_domain_id' => 7, 'local_part' => 'Sales', 'display_name' => 'Sales', + 'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 1024, + ])->assertRedirect(route('email.mailboxes.show', 9)); + } + + public function test_store_paid_mailbox_charges_tier_price(): void + { + // 25 GB tier = GHS 30 = 3000 (doubled) + $mc = $this->mailboxClient(); + $mc->shouldReceive('create')->once() + ->withArgs(fn ($pid, $did, $lp, $dn, $pw, $q, $paid, $ref) => $did === 7 && $q === 25600 && $paid === true && is_string($ref)) + ->andReturn(['id' => 5, 'address' => 'sales@acme.com']); + $w = $this->wallet(); + $w->shouldReceive('canAfford')->once()->with(Mockery::any(), 3000)->andReturn(true); + $w->shouldReceive('charge')->once()->withArgs(fn ($u, $amt, ...$r) => $amt === 3000)->andReturn(true); + + $this->actingAs($this->user())->post(route('email.mailboxes.store'), [ + 'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales', + 'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 25600, + ])->assertRedirect(route('email.mailboxes.show', 5)); + } + + public function test_store_rejects_invalid_quota(): void + { + $mc = $this->mailboxClient(); + $mc->shouldNotReceive('create'); + $this->wallet(); + + $this->actingAs($this->user())->from(route('email.mailboxes.create'))->post(route('email.mailboxes.store'), [ + 'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales', + 'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 999, + ])->assertSessionHasErrors('quota_mb'); + } + + public function test_store_paid_blocks_when_wallet_insufficient(): void + { + $mc = $this->mailboxClient(); + $mc->shouldNotReceive('create'); + $w = $this->wallet(); + $w->shouldReceive('canAfford')->once()->andReturn(false); + + $this->actingAs($this->user())->from(route('email.mailboxes.create'))->post(route('email.mailboxes.store'), [ + 'email_domain_id' => 7, 'local_part' => 'sales', 'display_name' => 'Sales', + 'password' => 'secret123', 'password_confirmation' => 'secret123', 'quota_mb' => 10240, + ])->assertRedirect(route('email.mailboxes.create'))->assertSessionHas('error'); + } + + public function test_upgrade_charges_new_tier_and_updates_quota(): void + { + $mc = $this->mailboxClient(); + $mc->shouldReceive('show')->andReturn(['id' => 3, 'address' => 'me@acme.com', 'quota_mb' => 1024]); // currently free 1 GB + $mc->shouldReceive('updateQuota')->once() + ->withArgs(fn ($pid, $id, $mb, $paid, $ref) => $id === 3 && $mb === 10240 && $paid === true && is_string($ref)) + ->andReturn(['id' => 3, 'quota_mb' => 10240]); + $w = $this->wallet(); + $w->shouldReceive('canAfford')->once()->with(Mockery::any(), 2000)->andReturn(true); // 10 GB = GHS 20 + $w->shouldReceive('charge')->once()->withArgs(fn ($u, $amt, ...$r) => $amt === 2000)->andReturn(true); + + $this->actingAs($this->user())->patch(route('email.mailboxes.upgrade.store', 3), ['quota_mb' => 10240]) + ->assertRedirect(route('email.mailboxes.show', 3)); + } + + public function test_upgrade_rejects_smaller_or_equal_plan(): void + { + $mc = $this->mailboxClient(); + $mc->shouldReceive('show')->andReturn(['id' => 3, 'address' => 'me@acme.com', 'quota_mb' => 10240]); + $mc->shouldNotReceive('updateQuota'); + $w = $this->wallet(); + $w->shouldNotReceive('charge'); + + $this->actingAs($this->user())->from(route('email.mailboxes.show', 3)) + ->patch(route('email.mailboxes.upgrade.store', 3), ['quota_mb' => 5120]) + ->assertRedirect(route('email.mailboxes.show', 3))->assertSessionHas('error'); + } + + public function test_domains_index_lists_from_api(): void + { + $dc = Mockery::mock(EmailDomainClient::class); + $dc->shouldReceive('forUser')->andReturn([['id' => 1, 'domain' => 'acme.com', 'active' => false]]); + $this->app->instance(EmailDomainClient::class, $dc); + + $this->actingAs($this->user())->get(route('email.domains.index')) + ->assertOk()->assertSee('acme.com')->assertSee('Pending verification'); + } +} diff --git a/tests/Feature/MarketingHostingCatalogTest.php b/tests/Feature/MarketingHostingCatalogTest.php new file mode 100644 index 0000000..e085945 --- /dev/null +++ b/tests/Feature/MarketingHostingCatalogTest.php @@ -0,0 +1,30 @@ +seed(HostingProductSeeder::class); + + $singleDomain = $this->get(route('products.hosting.single')); + $singleDomain->assertOk(); + $singleDomain->assertSee('Starter Plan'); + $singleDomain->assertSee('GHS 1.00'); + $singleDomain->assertSee('8 GB · 1 site'); + + $multiDomain = $this->get(route('products.hosting.multi')); + $multiDomain->assertOk(); + $multiDomain->assertSee('Pro Plan'); + $multiDomain->assertSee('GHS 175.00'); + $multiDomain->assertSee('225 GB · Unlimited sites'); + $multiDomain->assertDontSee('225 GB · -1 sites'); + } +} diff --git a/tests/Feature/ProvisionHostingOrderJobSharedTest.php b/tests/Feature/ProvisionHostingOrderJobSharedTest.php new file mode 100644 index 0000000..2a47869 --- /dev/null +++ b/tests/Feature/ProvisionHostingOrderJobSharedTest.php @@ -0,0 +1,132 @@ +create([ + 'email' => 'customer@example.com', + ]); + + $product = HostingProduct::create([ + 'name' => 'Starter Shared', + 'slug' => 'starter-shared-job-test', + 'category' => HostingProduct::CATEGORY_SHARED, + 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, + 'price_monthly' => 1, + 'currency' => 'GHS', + 'disk_gb' => 5, + 'max_domains' => 1, + 'is_active' => true, + 'is_visible' => true, + ]); + + $starterNode = HostingNode::create($this->nodeAttributes([ + 'name' => 'Starter Node', + 'hostname' => 'starter-node.local', + 'ip_address' => '192.0.2.20', + 'segment' => HostingNode::SEGMENT_STARTER, + ])); + + HostingNode::create($this->nodeAttributes([ + 'name' => 'High Resource Node', + 'hostname' => 'pro-node.local', + 'ip_address' => '192.0.2.21', + 'segment' => HostingNode::SEGMENT_HIGH_RESOURCE, + ])); + + $order = CustomerHostingOrder::create([ + 'user_id' => $user->id, + 'hosting_product_id' => $product->id, + 'domain_name' => 'starter-example.test', + 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, + 'amount_paid' => 1, + 'currency' => 'GHS', + 'status' => CustomerHostingOrder::STATUS_APPROVED, + ]); + + $sharedProvider = \Mockery::mock(SharedNodeProvider::class); + $sharedProvider->shouldReceive('createAccountOnNode') + ->once() + ->withArgs(function (HostingNode $node, array $config) use ($starterNode): bool { + $this->assertSame($starterNode->id, $node->id); + $this->assertSame(5 * 1024, $config['disk_quota_mb']); + $this->assertSame('starter-example.test', $config['domain']); + + return true; + }) + ->andReturn([ + 'home_directory' => '/home/startacct', + 'document_root' => '/home/startacct/public_html', + ]); + $sharedProvider->shouldReceive('applyAccountResourceProfile')->once()->andReturn(true); + $sharedProvider->shouldReceive('installWordPress')->never(); + + (new ProvisionHostingOrderJob($order))->handle( + app(NodeCapacityService::class), + app(HostingResourcePolicyService::class), + $sharedProvider, + \Mockery::mock(ContaboProvider::class), + \Mockery::mock(ServerOrderService::class), + \Mockery::mock(ServerAgentBootstrapService::class), + \Mockery::mock(ServerAgentInstallerService::class), + ); + + $order->refresh(); + $account = HostingAccount::query()->firstOrFail(); + $site = HostedSite::query()->firstOrFail(); + + $this->assertSame(CustomerHostingOrder::STATUS_ACTIVE, $order->status); + $this->assertSame($starterNode->id, $order->hosting_node_id); + $this->assertSame($account->id, $order->hosting_account_id); + $this->assertSame($product->id, $account->hosting_product_id); + $this->assertSame(5, $account->allocated_disk_gb); + $this->assertSame('starter-example.test', $account->primary_domain); + $this->assertSame('starter-example.test', $site->domain); + $this->assertSame('primary', $site->type); + $this->assertDatabaseHas('provisioning_queue', [ + 'customer_hosting_order_id' => $order->id, + 'status' => ProvisioningQueueItem::STATUS_COMPLETED, + ]); + $this->assertSame('starter', data_get($order->meta, 'node_segment')); + } + + private function nodeAttributes(array $overrides = []): array + { + return array_merge([ + 'name' => 'Shared Node', + 'hostname' => 'shared-node.local', + 'ip_address' => '192.0.2.15', + 'type' => HostingNode::TYPE_SHARED, + 'segment' => HostingNode::SEGMENT_GENERAL, + 'provider' => HostingNode::PROVIDER_MANUAL, + 'disk_gb' => 100, + 'oversell_ratio' => 3, + 'max_accounts' => 100, + 'status' => HostingNode::STATUS_ACTIVE, + 'ssh_port' => '22', + ], $overrides); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..f35b4e7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +});