From b4e040999d540278bc21ba6f8b45057d65250688 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 12 Oct 2026 13:25:16 +0100 Subject: [PATCH] move into sonardesk --- .scannerwork/.sonar_lock | 0 .scannerwork/report-task.txt | 6 + INTEGRATION_STATUS.md | 16 + .../Commands/SonarDeskAccountsBackfill.php | 59 ++ .../Console/Commands/SonarDeskAdminCreate.php | 36 + .../app/Console/Commands/SubmitIndexNow.php | 59 ++ .../Controllers/Admin/AdminController.php | 49 ++ .../Controllers/Admin/CmsPageController.php | 59 ++ .../Admin/DownloadProductController.php | 30 + .../Controllers/Admin/ReleaseController.php | 101 +++ .../Admin/SeoSettingsController.php | 50 ++ .../Admin/SupportTicketController.php | 59 ++ .../Http/Controllers/Admin/UserController.php | 156 ++++ .../Auth/AuthenticatedSessionController.php | 35 + .../Auth/EmailVerificationController.php | 31 + .../Controllers/Auth/PasswordController.php | 43 + .../Auth/RegisteredUserController.php | 29 + .../Http/Controllers/CmsPageController.php | 14 + .../app/Http/Controllers/Controller.php | 8 + .../Http/Controllers/ReleaseController.php | 85 ++ .../app/Http/Controllers/SeoController.php | 65 ++ .../Controllers/Support/SupportController.php | 92 ++ .../app/Http/Middleware/AdminOnly.php | 17 + .../sonardesk-legacy/app/Models/CmsPage.php | 22 + .../app/Models/DownloadProduct.php | 8 + .../app/Models/SiteSetting.php | 32 + .../app/Models/SonarDeskRelease.php | 40 + .../app/Models/SupportTicket.php | 46 + .../sonardesk-legacy/app/Models/User.php | 29 + .../app/Notifications/NewSupportTicket.php | 43 + .../app/Providers/AppServiceProvider.php | 17 + .../Accounts/PurchasedAccountProvisioner.php | 108 +++ .../app/Services/IndexNowService.php | 65 ++ .../Payments/CustomerAccountProvisioner.php | 63 ++ .../Payments/LemonSqueezyWebhookVerifier.php | 22 + .../app/Services/Payments/WebhookVerifier.php | 10 + .../sonardesk-legacy/config/indexnow.php | 7 + .../sonardesk-legacy/config/sonardesk.php | 35 + .../database/factories/UserFactory.php | 34 + ...026_09_08_193343_create_sessions_table.php | 31 + ...0000_create_sonardesk_licensing_tables.php | 92 ++ .../2026_09_08_200758_create_cache_table.php | 35 + ...08_205000_create_sonardesk_auth_tables.php | 37 + ..._08_210000_add_sonardesk_portal_fields.php | 30 + ..._100000_add_paddle_subscription_fields.php | 34 + ...eate_support_tickets_and_notifications.php | 47 ++ ...26_09_29_100000_create_cms_pages_table.php | 25 + ...150000_create_sonardesk_releases_table.php | 42 + ...0000_add_professional_seo_to_cms_pages.php | 35 + ..._090000_add_disabled_at_to_users_table.php | 22 + ..._10_02_100000_create_failed_jobs_table.php | 26 + ..._100000_create_download_products_table.php | 18 + .../modules/Licensing/Config/.gitkeep | 0 .../modules/Licensing/Config/config.php | 8 + .../modules/Licensing/Console/.gitkeep | 0 .../Console/SonarDeskLicenseDeliver.php | 20 + .../Console/SonarDeskLicenseIssue.php | 29 + .../Console/SonarDeskLicenseRevoke.php | 22 + .../Console/SonarDeskProductCreate.php | 21 + .../Licensing/Database/Migrations/.gitkeep | 0 ...0000_create_sonardesk_licensing_tables.php | 92 ++ ..._08_210000_add_sonardesk_portal_fields.php | 30 + .../Licensing/Database/Seeders/.gitkeep | 0 .../Seeders/LicensingDatabaseSeeder.php | 21 + .../Licensing/Database/factories/.gitkeep | 0 .../modules/Licensing/Helpers/.gitkeep | 0 .../modules/Licensing/Helpers/Helper.php | 12 + .../Licensing/Http/Controllers/.gitkeep | 0 .../Api/LicenseActivationController.php | 164 ++++ .../Api/PaymentWebhookController.php | 191 +++++ .../Http/Controllers/LicensingController.php | 79 ++ .../Controllers/Portal/PortalController.php | 143 ++++ .../Licensing/Http/Middleware/.gitkeep | 0 .../modules/Licensing/Http/Requests/.gitkeep | 0 .../modules/Licensing/Models/.gitkeep | 0 .../modules/Licensing/Models/License.php | 36 + .../Licensing/Models/LicenseActivation.php | 14 + .../Models/LicenseDeliveryOutbox.php | 20 + .../Licensing/Models/LicenseProduct.php | 12 + .../Licensing/Models/LicenseWebhookEvent.php | 12 + .../modules/Licensing/Providers/.gitkeep | 0 .../Providers/LicensingServiceProvider.php | 171 ++++ .../Providers/RouteServiceProvider.php | 69 ++ .../Licensing/Resources/assets/.gitkeep | 0 .../Licensing/Resources/assets/js/app.js | 0 .../Licensing/Resources/assets/sass/app.scss | 0 .../modules/Licensing/Resources/lang/.gitkeep | 0 .../Licensing/Resources/views/.gitkeep | 0 .../Licensing/Resources/views/index.blade.php | 9 + .../Resources/views/layouts/master.blade.php | 19 + .../modules/Licensing/Routes/.gitkeep | 0 .../modules/Licensing/Routes/api.php | 12 + .../modules/Licensing/Routes/web.php | 13 + .../Services/LicenseDeliveryService.php | 54 ++ .../Licensing/Services/LicenseIssuer.php | 46 + .../modules/Licensing/Services/LicenseKey.php | 27 + .../Licensing/Services/LicenseSigner.php | 59 ++ .../Services/LicenseTokenVerifier.php | 58 ++ .../modules/Licensing/Support/.gitkeep | 0 .../modules/Licensing/Support/ModuleInfo.php | 26 + .../modules/Licensing/Tests/Browser/.gitkeep | 0 .../modules/Licensing/Tests/Feature/.gitkeep | 0 .../modules/Licensing/Tests/Unit/.gitkeep | 0 .../modules/Licensing/Traits/.gitkeep | 0 .../modules/Licensing/View/.gitkeep | 0 .../modules/Licensing/composer.json | 15 + .../modules/Licensing/module.json | 14 + .../modules/Licensing/package.json | 17 + .../modules/Licensing/webpack.mix.js | 11 + .../modules/Paddle/Config/.gitkeep | 0 .../modules/Paddle/Config/config.php | 16 + .../modules/Paddle/Console/.gitkeep | 0 .../Paddle/Database/Migrations/.gitkeep | 0 ..._100000_add_paddle_subscription_fields.php | 34 + .../modules/Paddle/Database/Seeders/.gitkeep | 0 .../Database/Seeders/PaddleCatalogSeeder.php | 29 + .../Database/Seeders/PaddleDatabaseSeeder.php | 21 + .../Paddle/Database/factories/.gitkeep | 0 .../modules/Paddle/Helpers/.gitkeep | 0 .../modules/Paddle/Helpers/Helper.php | 12 + .../modules/Paddle/Http/Controllers/.gitkeep | 0 .../Http/Controllers/CheckoutController.php | 40 + .../Http/Controllers/PaddleController.php | 79 ++ .../modules/Paddle/Http/Middleware/.gitkeep | 0 .../modules/Paddle/Http/Requests/.gitkeep | 0 .../modules/Paddle/Models/.gitkeep | 0 .../modules/Paddle/Providers/.gitkeep | 0 .../Providers/PaddleServiceProvider.php | 155 ++++ .../Paddle/Providers/RouteServiceProvider.php | 69 ++ .../modules/Paddle/Resources/assets/.gitkeep | 0 .../modules/Paddle/Resources/assets/js/app.js | 0 .../Paddle/Resources/assets/sass/app.scss | 0 .../modules/Paddle/Resources/lang/.gitkeep | 0 .../modules/Paddle/Resources/views/.gitkeep | 0 .../Paddle/Resources/views/index.blade.php | 9 + .../Resources/views/layouts/master.blade.php | 19 + .../modules/Paddle/Routes/.gitkeep | 0 .../modules/Paddle/Routes/api.php | 18 + .../modules/Paddle/Routes/web.php | 6 + .../modules/Paddle/Services/PaddleClient.php | 170 ++++ .../Paddle/Services/PaddleWebhookVerifier.php | 108 +++ .../Paddle/Services/PurchaseProvisioner.php | 796 ++++++++++++++++++ .../modules/Paddle/Support/.gitkeep | 0 .../modules/Paddle/Support/ModuleInfo.php | 26 + .../modules/Paddle/Tests/Browser/.gitkeep | 0 .../modules/Paddle/Tests/Feature/.gitkeep | 0 .../modules/Paddle/Tests/Unit/.gitkeep | 0 .../modules/Paddle/Traits/.gitkeep | 0 .../modules/Paddle/View/.gitkeep | 0 .../modules/Paddle/composer.json | 15 + .../modules/Paddle/module.json | 16 + .../modules/Paddle/package.json | 17 + .../modules/Paddle/webpack.mix.js | 11 + .../sonardesk-legacy/resources/css/app.css | 9 + .../sonardesk-legacy/resources/js/app.js | 1 + .../resources/views/admin/cms/form.blade.php | 24 + .../resources/views/admin/cms/index.blade.php | 9 + .../admin/download-products/index.blade.php | 15 + .../resources/views/admin/index.blade.php | 6 + .../resources/views/admin/license.blade.php | 1 + .../resources/views/admin/licenses.blade.php | 1 + .../views/admin/releases/form.blade.php | 9 + .../views/admin/releases/index.blade.php | 7 + .../views/admin/releases/settings.blade.php | 5 + .../resources/views/admin/seo/edit.blade.php | 39 + .../views/admin/support/index.blade.php | 24 + .../views/admin/support/show.blade.php | 14 + .../views/admin/users/index.blade.php | 31 + .../views/admin/users/show.blade.php | 57 ++ .../views/auth/forgot-password.blade.php | 1 + .../resources/views/auth/login.blade.php | 1 + .../resources/views/auth/register.blade.php | 1 + .../views/auth/reset-password.blade.php | 1 + .../views/auth/verify-email.blade.php | 1 + .../resources/views/checkout/buy.blade.php | 301 +++++++ .../resources/views/cms/show.blade.php | 14 + .../views/components/layouts/app.blade.php | 9 + .../views/downloads/appcast.blade.php | 2 + .../resources/views/downloads/index.blade.php | 18 + .../views/downloads/product.blade.php | 13 + .../views/downloads/sonardesk.blade.php | 4 + .../views/emails/license-key-text.blade.php | 23 + .../views/emails/license-key.blade.php | 20 + .../resources/views/features.blade.php | 15 + .../resources/views/layouts/app.blade.php | 529 ++++++++++++ .../resources/views/layouts/app.blade.php.tmp | 0 .../resources/views/legal/cookies.blade.php | 16 + .../resources/views/legal/privacy.blade.php | 39 + .../resources/views/legal/refund.blade.php | 102 +++ .../resources/views/legal/terms.blade.php | 146 ++++ .../resources/views/portal/index.blade.php | 114 +++ .../resources/views/portal/show.blade.php | 81 ++ .../resources/views/portal/welcome.blade.php | 50 ++ .../resources/views/release.blade.php | 16 + .../resources/views/seo/sitemap.blade.php | 18 + .../resources/views/support.blade.php | 100 +++ .../resources/views/welcome.blade.php | 223 +++++ integration/sonardesk-legacy/routes/api.php | 3 + .../sonardesk-legacy/routes/console.php | 10 + integration/sonardesk-legacy/routes/web.php | 99 +++ .../tests/Feature/CheckoutPageTest.php | 29 + .../tests/Feature/LicenseActivationTest.php | 85 ++ .../PaddleSubscriptionProvisioningTest.php | 132 +++ .../PortalSubscriptionManagementTest.php | 69 ++ .../tests/Feature/ReleaseDistributionTest.php | 45 + .../sonardesk-legacy/tests/TestCase.php | 10 + .../sonardesk-legacy/tests/Unit/.gitkeep | 0 resources/views/sonardesk/features.blade.php | 3 + resources/views/sonardesk/home.blade.php | 3 + resources/views/sonardesk/layout.blade.php | 1 + resources/views/sonardesk/pricing.blade.php | 3 + routes/sonardesk.php | 8 + 212 files changed, 7976 insertions(+) create mode 100644 .scannerwork/.sonar_lock create mode 100644 .scannerwork/report-task.txt create mode 100644 INTEGRATION_STATUS.md create mode 100644 integration/sonardesk-legacy/app/Console/Commands/SonarDeskAccountsBackfill.php create mode 100755 integration/sonardesk-legacy/app/Console/Commands/SonarDeskAdminCreate.php create mode 100644 integration/sonardesk-legacy/app/Console/Commands/SubmitIndexNow.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Admin/AdminController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/Admin/CmsPageController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/Admin/DownloadProductController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/Admin/ReleaseController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/Admin/SeoSettingsController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Admin/SupportTicketController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/Admin/UserController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Auth/AuthenticatedSessionController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Auth/EmailVerificationController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Auth/PasswordController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Auth/RegisteredUserController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/CmsPageController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Controller.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/ReleaseController.php create mode 100644 integration/sonardesk-legacy/app/Http/Controllers/SeoController.php create mode 100755 integration/sonardesk-legacy/app/Http/Controllers/Support/SupportController.php create mode 100755 integration/sonardesk-legacy/app/Http/Middleware/AdminOnly.php create mode 100644 integration/sonardesk-legacy/app/Models/CmsPage.php create mode 100644 integration/sonardesk-legacy/app/Models/DownloadProduct.php create mode 100644 integration/sonardesk-legacy/app/Models/SiteSetting.php create mode 100644 integration/sonardesk-legacy/app/Models/SonarDeskRelease.php create mode 100755 integration/sonardesk-legacy/app/Models/SupportTicket.php create mode 100755 integration/sonardesk-legacy/app/Models/User.php create mode 100755 integration/sonardesk-legacy/app/Notifications/NewSupportTicket.php create mode 100644 integration/sonardesk-legacy/app/Providers/AppServiceProvider.php create mode 100644 integration/sonardesk-legacy/app/Services/Accounts/PurchasedAccountProvisioner.php create mode 100644 integration/sonardesk-legacy/app/Services/IndexNowService.php create mode 100644 integration/sonardesk-legacy/app/Services/Payments/CustomerAccountProvisioner.php create mode 100755 integration/sonardesk-legacy/app/Services/Payments/LemonSqueezyWebhookVerifier.php create mode 100755 integration/sonardesk-legacy/app/Services/Payments/WebhookVerifier.php create mode 100644 integration/sonardesk-legacy/config/indexnow.php create mode 100644 integration/sonardesk-legacy/config/sonardesk.php create mode 100644 integration/sonardesk-legacy/database/factories/UserFactory.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_08_193343_create_sessions_table.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_08_200758_create_cache_table.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php create mode 100755 integration/sonardesk-legacy/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_09_29_100000_create_cms_pages_table.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_10_02_100000_create_failed_jobs_table.php create mode 100644 integration/sonardesk-legacy/database/migrations/2026_10_12_100000_create_download_products_table.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Config/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Config/config.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Console/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseDeliver.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseIssue.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseRevoke.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskProductCreate.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Database/Migrations/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Database/Seeders/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Database/factories/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Helpers/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Helpers/Helper.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Http/Controllers/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Api/LicenseActivationController.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Http/Controllers/LicensingController.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Portal/PortalController.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Http/Middleware/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Http/Requests/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Models/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Licensing/Models/License.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Models/LicenseActivation.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Models/LicenseDeliveryOutbox.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Models/LicenseProduct.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Models/LicenseWebhookEvent.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Providers/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Providers/LicensingServiceProvider.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Providers/RouteServiceProvider.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/assets/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/assets/js/app.js create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/assets/sass/app.scss create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/lang/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/views/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/views/index.blade.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Resources/views/layouts/master.blade.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Routes/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Routes/api.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Routes/web.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Services/LicenseDeliveryService.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Services/LicenseIssuer.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Services/LicenseKey.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Services/LicenseSigner.php create mode 100755 integration/sonardesk-legacy/modules/Licensing/Services/LicenseTokenVerifier.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Support/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Support/ModuleInfo.php create mode 100644 integration/sonardesk-legacy/modules/Licensing/Tests/Browser/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Tests/Feature/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Tests/Unit/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/Traits/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/View/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Licensing/composer.json create mode 100644 integration/sonardesk-legacy/modules/Licensing/module.json create mode 100644 integration/sonardesk-legacy/modules/Licensing/package.json create mode 100644 integration/sonardesk-legacy/modules/Licensing/webpack.mix.js create mode 100644 integration/sonardesk-legacy/modules/Paddle/Config/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Config/config.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Console/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Database/Migrations/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Database/Seeders/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Database/factories/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Helpers/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Helpers/Helper.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Http/Controllers/.gitkeep create mode 100755 integration/sonardesk-legacy/modules/Paddle/Http/Controllers/CheckoutController.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Http/Controllers/PaddleController.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Http/Middleware/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Http/Requests/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Models/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Providers/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Providers/PaddleServiceProvider.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Providers/RouteServiceProvider.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/assets/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/assets/js/app.js create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/assets/sass/app.scss create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/lang/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/views/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/views/index.blade.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Resources/views/layouts/master.blade.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Routes/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Routes/api.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Routes/web.php create mode 100755 integration/sonardesk-legacy/modules/Paddle/Services/PaddleClient.php create mode 100755 integration/sonardesk-legacy/modules/Paddle/Services/PaddleWebhookVerifier.php create mode 100755 integration/sonardesk-legacy/modules/Paddle/Services/PurchaseProvisioner.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Support/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Support/ModuleInfo.php create mode 100644 integration/sonardesk-legacy/modules/Paddle/Tests/Browser/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Tests/Feature/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Tests/Unit/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/Traits/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/View/.gitkeep create mode 100644 integration/sonardesk-legacy/modules/Paddle/composer.json create mode 100644 integration/sonardesk-legacy/modules/Paddle/module.json create mode 100644 integration/sonardesk-legacy/modules/Paddle/package.json create mode 100644 integration/sonardesk-legacy/modules/Paddle/webpack.mix.js create mode 100755 integration/sonardesk-legacy/resources/css/app.css create mode 100755 integration/sonardesk-legacy/resources/js/app.js create mode 100644 integration/sonardesk-legacy/resources/views/admin/cms/form.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/cms/index.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/download-products/index.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/admin/index.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/admin/license.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/admin/licenses.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/releases/form.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/releases/index.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/releases/settings.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/seo/edit.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/admin/support/index.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/admin/support/show.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/users/index.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/admin/users/show.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/auth/forgot-password.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/auth/login.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/auth/register.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/auth/reset-password.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/auth/verify-email.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/checkout/buy.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/cms/show.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/components/layouts/app.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/downloads/appcast.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/downloads/index.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/downloads/product.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/downloads/sonardesk.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/emails/license-key-text.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/emails/license-key.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/features.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/layouts/app.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/layouts/app.blade.php.tmp create mode 100755 integration/sonardesk-legacy/resources/views/legal/cookies.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/legal/privacy.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/legal/refund.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/legal/terms.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/portal/index.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/portal/show.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/portal/welcome.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/release.blade.php create mode 100644 integration/sonardesk-legacy/resources/views/seo/sitemap.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/support.blade.php create mode 100755 integration/sonardesk-legacy/resources/views/welcome.blade.php create mode 100755 integration/sonardesk-legacy/routes/api.php create mode 100755 integration/sonardesk-legacy/routes/console.php create mode 100644 integration/sonardesk-legacy/routes/web.php create mode 100755 integration/sonardesk-legacy/tests/Feature/CheckoutPageTest.php create mode 100755 integration/sonardesk-legacy/tests/Feature/LicenseActivationTest.php create mode 100755 integration/sonardesk-legacy/tests/Feature/PaddleSubscriptionProvisioningTest.php create mode 100755 integration/sonardesk-legacy/tests/Feature/PortalSubscriptionManagementTest.php create mode 100644 integration/sonardesk-legacy/tests/Feature/ReleaseDistributionTest.php create mode 100644 integration/sonardesk-legacy/tests/TestCase.php create mode 100644 integration/sonardesk-legacy/tests/Unit/.gitkeep create mode 100644 resources/views/sonardesk/features.blade.php create mode 100644 resources/views/sonardesk/home.blade.php create mode 100644 resources/views/sonardesk/layout.blade.php create mode 100644 resources/views/sonardesk/pricing.blade.php create mode 100644 routes/sonardesk.php diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock new file mode 100644 index 0000000..e69de29 diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt new file mode 100644 index 0000000..28e3cdd --- /dev/null +++ b/.scannerwork/report-task.txt @@ -0,0 +1,6 @@ +projectKey=Content +serverUrl=http://192.168.1.2:9001 +serverVersion=9.9.8.100196 +dashboardUrl=http://192.168.1.2:9001/dashboard?id=Content +ceTaskId=AaCkx0myX_DhBSeKgkyS +ceTaskUrl=http://192.168.1.2:9001/api/ce/task?id=AaCkx0myX_DhBSeKgkyS diff --git a/INTEGRATION_STATUS.md b/INTEGRATION_STATUS.md new file mode 100644 index 0000000..eb60e8a --- /dev/null +++ b/INTEGRATION_STATUS.md @@ -0,0 +1,16 @@ +# ABSDev / SonarDesk integration — development scaffold + +**NOT PRODUCTION READY. DO NOT DEPLOY OVER AN EXISTING LICENSING SERVER.** + +Implemented: +- Preserved ABSDev application as base. +- Added SonarDesk-host-specific marketing routes and responsive ABSDev-inspired navy/gold views. +- Archived licensing source under `integration/sonardesk-legacy/` for a controlled migration. This directory is **not** wired into runtime. + +Not implemented / not verified: +- Licensing API, existing download/appcast routes, payment checkout/webhooks, customer identity mapping, backend administration, data migrations and theme-manager integration. +- Laravel test suite, browser tests, PHP dependencies and deployment verification. + +**Important:** Existing unscoped ABSDev routes and module routes may still collide with the SonarDesk host. The new domain routes are only an initial frontend scaffold. Do not point production SonarDesk DNS at this build. + +Next steps: inventory and namespace every licensing route, reconcile User schema and Fortify authentication, port modules and database migrations without collisions, wire checkout and webhooks, add regression tests, and test on staging with anonymised production data. diff --git a/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAccountsBackfill.php b/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAccountsBackfill.php new file mode 100644 index 0000000..62798b2 --- /dev/null +++ b/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -0,0 +1,59 @@ +whereNotNull('customer_email') + ->where('customer_email', '<>', '') + ->select(['customer_email', 'customer_name']) + ->orderBy('customer_email') + ->get() + ->unique(fn (License $license) => strtolower(trim((string) $license->customer_email))); + + $missing = 0; + $created = 0; + + foreach ($customers as $license) { + $email = strtolower(trim((string) $license->customer_email)); + if ($email === '') { + continue; + } + + if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) { + continue; + } + + $missing++; + if ($this->option('dry-run')) { + $this->line("MISSING {$email}"); + continue; + } + + if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) { + $created++; + $this->info("CREATED {$email}"); + } + } + + if ($this->option('dry-run')) { + $this->info("{$missing} account(s) would be created."); + } else { + $this->info("{$created} account(s) created."); + } + + return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAdminCreate.php b/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAdminCreate.php new file mode 100755 index 0000000..2ffb683 --- /dev/null +++ b/integration/sonardesk-legacy/app/Console/Commands/SonarDeskAdminCreate.php @@ -0,0 +1,36 @@ +argument('email'))); + $password = Str::password(24, true, true, false, false); + $u = User::firstOrNew(['email' => $email]); + $u->name = $this->option('name'); + $u->is_admin = true; + $u->email_verified_at = now(); + if (! $u->exists) { + $u->password = Hash::make($password); + } $u->save(); + $this->info("Administrator ready: {$email}"); + if (! $u->wasRecentlyCreated) { + $this->line('Existing password retained.'); + } else { + $this->warn("Temporary password (shown once): {$password}"); + } + +return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/app/Console/Commands/SubmitIndexNow.php b/integration/sonardesk-legacy/app/Console/Commands/SubmitIndexNow.php new file mode 100644 index 0000000..214752e --- /dev/null +++ b/integration/sonardesk-legacy/app/Console/Commands/SubmitIndexNow.php @@ -0,0 +1,59 @@ +warn('IndexNow is disabled. Set INDEXNOW_ENABLED=true to enable submissions.'); + return self::FAILURE; + } + + $urls = $this->argument('urls'); + if ($this->option('all') || $urls === []) { + $urls = $this->allPublicUrls(); + } + + try { + $response = $indexNow->submit($urls); + } catch (Throwable $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + if ($response === null || ! $response->successful()) { + $this->error('IndexNow rejected the submission'.($response ? ' (HTTP '.$response->status().').' : '.')); + return self::FAILURE; + } + + $this->info('Submitted '.count($urls).' URL(s) to IndexNow (HTTP '.$response->status().').'); + return self::SUCCESS; + } + + /** @return array */ + private function allPublicUrls(): array + { + $routes = ['home', 'features', 'buy', 'release', 'support', 'privacy', 'terms', 'refund', 'cookies']; + $urls = collect($routes)->filter(fn (string $name) => Route::has($name))->map(fn (string $name) => route($name)); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->pluck('slug') + ->map(fn (string $slug) => route('cms.show', ['slug' => $slug])); + + return $urls->merge($pages)->unique()->values()->all(); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/AdminController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/AdminController.php new file mode 100755 index 0000000..2609d01 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/AdminController.php @@ -0,0 +1,49 @@ + License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]); + } + + public function licenses(Request $r) + { + $q = License::with('product')->latest(); + if ($s = trim((string) $r->query('q'))) { + $q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%")); + } + +return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]); + } + + public function show(License $license) + { + $license->load(['product', 'activations']); + + return view('admin.license', compact('license')); + } + + public function revoke(Request $request, License $license) + { + $data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]); + $license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']); + + return back()->with('status', 'Licence revoked.'); + } + + public function restore(License $license) + { + $license->update(['revoked_at' => null, 'revoke_reason' => null]); + + return back()->with('status','Licence restored.'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/CmsPageController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/CmsPageController.php new file mode 100644 index 0000000..720a62c --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/CmsPageController.php @@ -0,0 +1,59 @@ + CmsPage::latest()->paginate(25)]); } + public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } + public function store(Request $request, IndexNowService $indexNow) { $page = CmsPage::create($this->validated($request)); $this->submitIfIndexable($page, $indexNow); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } + public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } + public function update(Request $request, CmsPage $page, IndexNowService $indexNow) { $page->update($this->validated($request, $page)); $this->submitIfIndexable($page, $indexNow); return back()->with('status', 'Page updated.'); } + public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } + + private function submitIfIndexable(CmsPage $page, IndexNowService $indexNow): void + { + if (! $page->is_published || ! $page->robots_index || ($page->published_at && $page->published_at->isFuture())) { + return; + } + + try { + $indexNow->submit([route('cms.show', ['slug' => $page->slug])]); + } catch (\Throwable $e) { + report($e); + } + } + + private function validated(Request $request, ?CmsPage $page = null): array + { + $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); + $data = $request->validate([ + 'title' => ['required','string','max:160'], + 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], + 'excerpt' => ['nullable','string','max:1000'], + 'body' => ['required','string'], + 'meta_title' => ['nullable','string','max:160'], + 'meta_description' => ['nullable','string','max:320'], + 'meta_keywords' => ['nullable','string','max:500'], + 'canonical_url' => ['nullable','url:http,https','max:2048'], + 'og_title' => ['nullable','string','max:160'], + 'og_description' => ['nullable','string','max:320'], + 'og_image' => ['nullable','url:http,https','max:2048'], + 'sitemap_priority' => ['required','numeric','min:0','max:1'], + 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], + ]); + $data['robots_index'] = $request->boolean('robots_index'); + $data['robots_follow'] = $request->boolean('robots_follow'); + $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); + $data['is_published'] = $request->boolean('is_published'); + $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; + return $data; + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/DownloadProductController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/DownloadProductController.php new file mode 100644 index 0000000..8bc4a21 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/DownloadProductController.php @@ -0,0 +1,30 @@ +DownloadProduct::latest()->get()]); } + public function store(Request $request) { + $data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'required|file|max:1048576','is_published'=>'sometimes|boolean']); + $file=$request->file('package'); + $path=$file->store('download-products','local'); + DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>$request->boolean('is_published')]); + return back()->with('status','Product added.'); + } + public function update(Request $request, DownloadProduct $product) { + $data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'nullable|file|max:1048576','is_published'=>'sometimes|boolean']); + $changes=['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'is_published'=>$request->boolean('is_published')]; + if ($request->hasFile('package')) { $file=$request->file('package'); $path=$file->store('download-products','local'); $old=$product->file_path; $changes+=['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()]; $product->update($changes); if($old) Storage::disk('local')->delete($old); } + else $product->update($changes); + return back()->with('status','Product updated.'); + } + public function publication(Request $request, DownloadProduct $product) { + $data = $request->validate(['is_published' => 'required|boolean']); + $product->update(['is_published' => (bool) $data['is_published']]); + return back()->with('status', $product->is_published ? 'Product published.' : 'Product unpublished.'); + } + public function destroy(DownloadProduct $product) { if($product->file_path) Storage::disk('local')->delete($product->file_path); $product->delete(); return back()->with('status','Product deleted.'); } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/ReleaseController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/ReleaseController.php new file mode 100644 index 0000000..2d2b5e2 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/ReleaseController.php @@ -0,0 +1,101 @@ + SonarDeskRelease::latest()->paginate(30), + 'publishedCount' => SonarDeskRelease::where('is_published', true)->count(), + 'downloadCount' => SonarDeskRelease::sum('download_count'), + ]); + } + + public function create() { return view('admin.releases.form', ['release' => new SonarDeskRelease]); } + public function edit(SonarDeskRelease $release) { return view('admin.releases.form', compact('release')); } + + public function store(Request $request): RedirectResponse + { + $release = new SonarDeskRelease; + $this->persist($request, $release); + return redirect()->route('admin.releases.index')->with('status', 'Release created.'); + } + + public function update(Request $request, SonarDeskRelease $release): RedirectResponse + { + $this->persist($request, $release); + return redirect()->route('admin.releases.index')->with('status', 'Release updated.'); + } + + public function destroy(SonarDeskRelease $release): RedirectResponse + { + if ($release->file_path) Storage::disk('local')->delete($release->file_path); + $release->delete(); + return back()->with('status', 'Release deleted.'); + } + + public function settings() + { + $keys = ['downloads.eyebrow','downloads.title','downloads.intro','downloads.requirements','downloads.support_text']; + $settings = collect($keys)->mapWithKeys(fn ($key) => [$key => SiteSetting::valueFor($key)]); + return view('admin.releases.settings', compact('settings')); + } + + public function updateSettings(Request $request): RedirectResponse + { + $data = $request->validate([ + 'eyebrow' => ['required','string','max:80'], 'title' => ['required','string','max:120'], + 'intro' => ['required','string','max:500'], 'requirements' => ['required','string','max:500'], + 'support_text' => ['required','string','max:500'], + ]); + foreach ($data as $key => $value) SiteSetting::put('downloads.'.$key, $value); + return back()->with('status', 'Download page content updated.'); + } + + private function persist(Request $request, SonarDeskRelease $release): void + { + $data = $request->validate([ + 'version' => ['required','string','max:40'], 'build_number' => ['required','integer','min:1'], + 'channel' => ['required', Rule::in(['stable','beta'])], 'minimum_macos' => ['nullable','string','max:30'], + 'release_notes' => ['nullable','string','max:20000'], 'sparkle_signature' => ['nullable','string','max:2000'], + 'package' => [ + $release->exists ? 'nullable' : 'required', + 'file', + 'max:1048576', + function (string $attribute, $value, \Closure $fail): void { + $extension = strtolower($value->getClientOriginalExtension()); + if (! in_array($extension, ['dmg', 'zip'], true)) { + $fail('The package must be a DMG or ZIP file.'); + } + }, + ], + 'is_published' => ['nullable','boolean'], 'is_featured' => ['nullable','boolean'], + ]); + $data['is_published'] = $request->boolean('is_published'); + $data['is_featured'] = $request->boolean('is_featured'); + if ($data['is_featured']) SonarDeskRelease::query()->update(['is_featured' => false]); + + if ($request->hasFile('package')) { + if ($release->file_path) Storage::disk('local')->delete($release->file_path); + $file = $request->file('package'); + $path = $file->store('sonardesk/releases', 'local'); + $data['file_path'] = $path; + $data['original_filename'] = $file->getClientOriginalName(); + $data['file_size'] = $file->getSize(); + $data['sha256'] = hash_file('sha256', $file->getRealPath()); + } + unset($data['package']); + $data['published_at'] = $data['is_published'] ? ($release->published_at ?: now()) : null; + $release->fill($data)->save(); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/SeoSettingsController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/SeoSettingsController.php new file mode 100644 index 0000000..a7f920d --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/SeoSettingsController.php @@ -0,0 +1,50 @@ + collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), + 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'seo_site_name' => ['required', 'string', 'max:80'], + 'seo_title_suffix' => ['nullable', 'string', 'max:80'], + 'seo_default_title' => ['required', 'string', 'max:160'], + 'seo_default_description' => ['required', 'string', 'max:320'], + 'seo_default_keywords' => ['nullable', 'string', 'max:500'], + 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], + 'seo_organization_name' => ['nullable', 'string', 'max:120'], + 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], + ]); + + foreach (self::KEYS as $key) { + SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); + } + SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); + + return back()->with('status', 'SEO settings updated.'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/SupportTicketController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/SupportTicketController.php new file mode 100755 index 0000000..99484af --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/SupportTicketController.php @@ -0,0 +1,59 @@ +with('assignee') + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->latest() + ->paginate(50) + ->withQueryString(); + + return view('admin.support.index', compact('tickets')); + } + + public function show(SupportTicket $ticket) + { + $ticket->load(['user', 'assignee']); + if (auth()->user()) { + auth()->user()->unreadNotifications()->get() + ->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id) + ->each->markAsRead(); + } + + return view('admin.support.show', [ + 'ticket' => $ticket, + 'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']), + ]); + } + + public function update(Request $request, SupportTicket $ticket): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'in:new,open,waiting,resolved,closed'], + 'priority' => ['required', 'in:low,normal,high,urgent'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + ]); + + if ($data['assigned_to'] ?? null) { + abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422); + } + + $data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true) + ? ($ticket->resolved_at ?? now()) + : null; + $ticket->update($data); + + return back()->with('status', 'Support ticket updated.'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Admin/UserController.php b/integration/sonardesk-legacy/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..f37a764 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,156 @@ +latest(); + + if ($search = trim((string) $request->query('q'))) { + $query->where(fn ($q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + } + + if ($request->query('status') === 'admin') { + $query->where('is_admin', true); + } elseif ($request->query('status') === 'suspended') { + $query->whereNotNull('disabled_at'); + } elseif ($request->query('status') === 'unverified') { + $query->whereNull('email_verified_at'); + } + + $users = $query->paginate(50)->withQueryString(); + $emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all(); + $licenseCounts = License::query() + ->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate') + ->whereIn(DB::raw('LOWER(customer_email)'), $emails) + ->groupBy(DB::raw('LOWER(customer_email)')) + ->pluck('aggregate', 'email_key'); + + return view('admin.users.index', compact('users', 'licenseCounts')); + } + + public function show(User $user) + { + $licenses = License::with(['product', 'activations']) + ->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)]) + ->latest() + ->get(); + + return view('admin.users.show', compact('user', 'licenses')); + } + + public function update(Request $request, User $user) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'is_admin' => ['nullable', 'boolean'], + ]); + + $oldEmail = strtolower($user->email); + $newEmail = strtolower(trim($data['email'])); + $isSelf = $request->user()->is($user); + + DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void { + $user->name = trim($data['name']); + $user->email = $newEmail; + if (! $isSelf) { + $user->is_admin = (bool) ($data['is_admin'] ?? false); + } + $user->save(); + + if ($oldEmail !== $newEmail) { + License::whereRaw('LOWER(customer_email) = ?', [$oldEmail]) + ->update(['customer_email' => $newEmail]); + } + }); + + return redirect()->route('admin.users.show', $user)->with('status', 'User updated.'); + } + + public function verify(User $user) + { + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + return back()->with('status', 'Email marked as verified.'); + } + + public function resendVerification(User $user) + { + if (! $user->hasVerifiedEmail()) { + $user->sendEmailVerificationNotification(); + } + + return back()->with('status', 'Verification email sent.'); + } + + public function sendPasswordReset(User $user) + { + $status = Password::sendResetLink(['email' => $user->email]); + + return $status === Password::RESET_LINK_SENT + ? back()->with('status', 'Password reset link sent.') + : back()->withErrors(['email' => __($status)]); + } + + public function suspend(Request $request, User $user) + { + abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.'); + $user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save(); + + return back()->with('status', 'User suspended.'); + } + + public function restore(User $user) + { + $user->forceFill(['disabled_at' => null])->save(); + + return back()->with('status', 'User restored.'); + } + + public function destroy(Request $request, User $user) + { + abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.'); + $user->delete(); + + return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.'); + } + + public function updateLicense(Request $request, User $user, License $license) + { + $this->ensureOwnsLicense($user, $license); + $data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]); + $license->update(['activation_limit' => $data['activation_limit']]); + + return back()->with('status', 'Activation limit updated.'); + } + + public function deactivate(User $user, License $license, LicenseActivation $activation) + { + $this->ensureOwnsLicense($user, $license); + abort_unless((string) $activation->license_id === (string) $license->id, 404); + $activation->update(['deactivated_at' => now()]); + + return back()->with('status', 'Device activation released.'); + } + + private function ensureOwnsLicense(User $user, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/integration/sonardesk-legacy/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100755 index 0000000..ebe9dc1 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,35 @@ +validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); + if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) { + return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email'); + } + $request->session()->regenerate(); + + return redirect()->intended(route('portal.index')); + } + + public function destroy(Request $request) + { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Auth/EmailVerificationController.php b/integration/sonardesk-legacy/app/Http/Controllers/Auth/EmailVerificationController.php new file mode 100755 index 0000000..2f38de1 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Auth/EmailVerificationController.php @@ -0,0 +1,31 @@ +fulfill(); + + return redirect()->route('portal.index')->with('status', 'Email verified.'); + } + + public function resend(Request $request) + { + if (! $request->user()->hasVerifiedEmail()) { + $request->user()->sendEmailVerificationNotification(); + } + +return back()->with('status', 'Verification link sent.'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Auth/PasswordController.php b/integration/sonardesk-legacy/app/Http/Controllers/Auth/PasswordController.php new file mode 100755 index 0000000..4eb6694 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,43 @@ +validate(['email' => ['required', 'email']]); + $status = Password::sendResetLink(['email' => strtolower($request->email)]); + + return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]); + } + + public function resetForm(Request $request, string $token) + { + return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]); + } + + public function reset(Request $request) + { + $data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); + $status = Password::reset($data, function (User $user, string $password) { + $user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save(); + event(new PasswordReset($user)); + }); + + return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Auth/RegisteredUserController.php b/integration/sonardesk-legacy/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100755 index 0000000..3b5c073 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,29 @@ +validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]); + $user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]); + event(new Registered($user)); + Auth::login($user); + $request->session()->regenerate(); + + return redirect()->route('verification.notice'); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/CmsPageController.php b/integration/sonardesk-legacy/app/Http/Controllers/CmsPageController.php new file mode 100644 index 0000000..cb86e52 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/CmsPageController.php @@ -0,0 +1,14 @@ +where('is_published', true)->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail(); + return view('cms.show', compact('page')); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Controller.php b/integration/sonardesk-legacy/app/Http/Controllers/Controller.php new file mode 100755 index 0000000..ae7ff85 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +where('is_featured', true)->orderByDesc('published_at')->first() + ?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->first(); + $releases = SonarDeskRelease::published() + ->when($latest, fn ($query) => $query->whereKeyNot($latest->getKey())) + ->orderByDesc('published_at')->paginate(12); + $settings = $this->settings(); + $products = DownloadProduct::where('is_published', true)->whereNotNull('file_path') + ->when($latest, fn ($query) => $query->whereRaw('LOWER(name) <> ?', ['sonardesk'])) + ->orderBy('name')->get(); + + return view('downloads.index', compact('latest', 'releases', 'settings', 'products')); + } + + public function sonarDeskDetails() + { + $latest = SonarDeskRelease::published()->where('is_featured', true)->orderByDesc('published_at')->first() + ?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->firstOrFail(); + $settings = $this->settings(); + return view('downloads.sonardesk', compact('latest', 'settings')); + } + + public function latest(): BinaryFileResponse + { + $release = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->firstOrFail(); + return $this->send($release); + } + + public function download(SonarDeskRelease $release): BinaryFileResponse + { + abort_unless($release->is_published && $release->published_at && $release->file_path, 404); + return $this->send($release); + } + + public function productDetails(DownloadProduct $product) + { + abort_unless($product->is_published && $product->file_path, 404); + return view('downloads.product', compact('product')); + } + + public function productDownload(DownloadProduct $product): BinaryFileResponse + { + abort_unless($product->is_published && $product->file_path && Storage::disk('local')->exists($product->file_path), 404); + $product->increment('download_count'); + return response()->download(Storage::disk('local')->path($product->file_path), $product->original_filename); + } + + public function appcast(): Response + { + $releases = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->limit(20)->get(); + return response()->view('downloads.appcast', compact('releases'), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + } + + private function send(SonarDeskRelease $release): BinaryFileResponse + { + abort_unless(Storage::disk('local')->exists($release->file_path), 404); + $release->increment('download_count'); + return response()->download(Storage::disk('local')->path($release->file_path), $release->original_filename ?: 'SonarDesk-'.$release->version.'.dmg'); + } + + private function settings(): array + { + return [ + 'eyebrow' => SiteSetting::valueFor('downloads.eyebrow', 'SonarDesk for macOS'), + 'title' => SiteSetting::valueFor('downloads.title', 'Download SonarDesk'), + 'intro' => SiteSetting::valueFor('downloads.intro', 'A focused native SonarQube workspace, designed for macOS and built for daily engineering work.'), + 'requirements' => SiteSetting::valueFor('downloads.requirements', 'Apple silicon and Intel Macs supported. A SonarQube server and valid SonarDesk licence are required.'), + 'support_text' => SiteSetting::valueFor('downloads.support_text', 'Need help installing or activating SonarDesk? Our support team can help.'), + ]; + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/SeoController.php b/integration/sonardesk-legacy/app/Http/Controllers/SeoController.php new file mode 100644 index 0000000..21439fe --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/SeoController.php @@ -0,0 +1,65 @@ + 'home', 'priority' => '1.0', 'frequency' => 'weekly'], + ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], + ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], + ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], + ])->filter(fn (array $item) => Route::has($item['route']))->values(); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('include_in_sitemap', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->orderBy('slug') + ->get(); + + return response() + ->view('seo.sitemap', compact('static', 'pages')) + ->header('Content-Type', 'application/xml; charset=UTF-8') + ->header('Cache-Control', 'public, max-age=3600'); + } + + public function indexNowKey(string $key): Response + { + $configured = trim((string) config('indexnow.key')); + + abort_unless(config('indexnow.enabled') && $configured !== '' && hash_equals($configured, $key), 404); + + return response($configured, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=86400', + ]); + } + + public function robots(): Response + { + $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); + + $body = $indexable + ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/integration/sonardesk-legacy/app/Http/Controllers/Support/SupportController.php b/integration/sonardesk-legacy/app/Http/Controllers/Support/SupportController.php new file mode 100755 index 0000000..55d6a68 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Controllers/Support/SupportController.php @@ -0,0 +1,92 @@ + [ + 'technical' => 'Technical support', + 'licensing' => 'Licence & activation', + 'billing' => 'Billing & subscription', + 'feature' => 'Feature request', + 'privacy' => 'Privacy / data request', + 'general' => 'General enquiry', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + // Honeypot: bots tend to fill hidden fields. Return success without retaining spam. + if (filled($request->input('company_website'))) { + return back()->with('status', 'Your support request has been received.'); + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'email:rfc', 'max:254'], + 'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'], + 'subject' => ['required', 'string', 'max:180'], + 'message' => ['required', 'string', 'min:10', 'max:10000'], + 'privacy_notice' => ['accepted'], + ]); + + $ticket = SupportTicket::create([ + 'user_id' => $request->user()?->id, + 'reference' => $this->reference(), + 'name' => $data['name'], + 'email' => strtolower($data['email']), + 'category' => $data['category'], + 'subject' => $data['subject'], + 'message' => $data['message'], + 'privacy_notice_accepted_at' => now(), + ]); + + $this->notifyTeam($ticket); + + Mail::raw( + "We received your SonarDesk support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.", + fn ($mail) => $mail->to($ticket->email)->subject("SonarDesk support request {$ticket->reference}") + ); + + return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you."); + } + + private function notifyTeam(SupportTicket $ticket): void + { + if (config('sonardesk.support.notify_admin_users', true)) { + User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket)); + } + + $emails = collect(config('sonardesk.support.alert_emails', [])) + ->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values(); + + if ($emails->isNotEmpty()) { + Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket)); + } + } + + private function reference(): string + { + do { + $reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6)); + } while (SupportTicket::where('reference', $reference)->exists()); + + return $reference; + } +} diff --git a/integration/sonardesk-legacy/app/Http/Middleware/AdminOnly.php b/integration/sonardesk-legacy/app/Http/Middleware/AdminOnly.php new file mode 100755 index 0000000..e7cd3a6 --- /dev/null +++ b/integration/sonardesk-legacy/app/Http/Middleware/AdminOnly.php @@ -0,0 +1,17 @@ +user()?->is_admin === true, 403); + + return $next($request); + } +} diff --git a/integration/sonardesk-legacy/app/Models/CmsPage.php b/integration/sonardesk-legacy/app/Models/CmsPage.php new file mode 100644 index 0000000..3209346 --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/CmsPage.php @@ -0,0 +1,22 @@ + 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', + ]; + } +} diff --git a/integration/sonardesk-legacy/app/Models/DownloadProduct.php b/integration/sonardesk-legacy/app/Models/DownloadProduct.php new file mode 100644 index 0000000..431156c --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/DownloadProduct.php @@ -0,0 +1,8 @@ +'boolean','file_size'=>'integer','download_count'=>'integer']; } + public function getFormattedSizeAttribute(): string { return $this->file_size >= 1048576 ? number_format($this->file_size/1048576,1).' MB' : number_format($this->file_size/1024,1).' KB'; } +} diff --git a/integration/sonardesk-legacy/app/Models/SiteSetting.php b/integration/sonardesk-legacy/app/Models/SiteSetting.php new file mode 100644 index 0000000..872204e --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/SiteSetting.php @@ -0,0 +1,32 @@ + static::query()->find($key)?->value ?? $default); + } + + public static function booleanFor(string $key, bool $default = false): bool + { + $value = static::valueFor($key); + return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + public static function put(string $key, ?string $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget('sonardesk.setting.'.$key); + } +} diff --git a/integration/sonardesk-legacy/app/Models/SonarDeskRelease.php b/integration/sonardesk-legacy/app/Models/SonarDeskRelease.php new file mode 100644 index 0000000..df4dbb2 --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/SonarDeskRelease.php @@ -0,0 +1,40 @@ + 'boolean', + 'is_featured' => 'boolean', + 'published_at' => 'datetime', + 'file_size' => 'integer', + 'download_count' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true)->whereNotNull('file_path')->whereNotNull('published_at'); + } + + public function getFormattedSizeAttribute(): string + { + $bytes = (int) $this->file_size; + if ($bytes < 1024) return $bytes.' B'; + if ($bytes < 1048576) return number_format($bytes / 1024, 1).' KB'; + if ($bytes < 1073741824) return number_format($bytes / 1048576, 1).' MB'; + return number_format($bytes / 1073741824, 2).' GB'; + } +} diff --git a/integration/sonardesk-legacy/app/Models/SupportTicket.php b/integration/sonardesk-legacy/app/Models/SupportTicket.php new file mode 100755 index 0000000..ccbd655 --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/SupportTicket.php @@ -0,0 +1,46 @@ + 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function prunable(): Builder + { + $days = max(30, (int) config('sonardesk.privacy.support_retention_days', 730)); + + return static::query() + ->whereNotNull('resolved_at') + ->where('resolved_at', '<=', now()->subDays($days)); + } +} diff --git a/integration/sonardesk-legacy/app/Models/User.php b/integration/sonardesk-legacy/app/Models/User.php new file mode 100755 index 0000000..f593607 --- /dev/null +++ b/integration/sonardesk-legacy/app/Models/User.php @@ -0,0 +1,29 @@ + 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime']; + } +} diff --git a/integration/sonardesk-legacy/app/Notifications/NewSupportTicket.php b/integration/sonardesk-legacy/app/Notifications/NewSupportTicket.php new file mode 100755 index 0000000..704c05a --- /dev/null +++ b/integration/sonardesk-legacy/app/Notifications/NewSupportTicket.php @@ -0,0 +1,43 @@ +subject("New SonarDesk support request {$this->ticket->reference}") + ->greeting('New support request') + ->line("{$this->ticket->name} <{$this->ticket->email}>") + ->line("Category: {$this->ticket->category}") + ->line("Subject: {$this->ticket->subject}") + ->line($this->ticket->message) + ->action('Open support ticket', route('admin.support.show', $this->ticket)); + } + + public function toArray(object $notifiable): array + { + return [ + 'support_ticket_id' => $this->ticket->id, + 'reference' => $this->ticket->reference, + 'subject' => $this->ticket->subject, + 'email' => $this->ticket->email, + ]; + } +} diff --git a/integration/sonardesk-legacy/app/Providers/AppServiceProvider.php b/integration/sonardesk-legacy/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..66ffb66 --- /dev/null +++ b/integration/sonardesk-legacy/app/Providers/AppServiceProvider.php @@ -0,0 +1,17 @@ +findByEmail($email); + + if ($user) { + return $user; + } + + $created = false; + + try { + $user = User::create([ + 'name' => $this->displayName($name, $email), + 'email' => $email, + 'email_verified_at' => now(), + 'password' => Str::random(64), + 'is_admin' => false, + ]); + + $created = true; + } catch (UniqueConstraintViolationException $exception) { + /* + * Another webhook may have created this account after our SELECT + * but before our INSERT. In that case the unique email constraint + * correctly rejects our INSERT; simply reuse the account that won + * the race. + */ + $user = $this->findByEmail($email); + + if (! $user) { + /* + * The unique violation was not caused by the email account + * appearing concurrently, so preserve the original failure. + */ + throw $exception; + } + } + + /* + * Only the request that actually created the account should send the + * account setup/reset link. Concurrent Paddle events must not result + * in several setup emails being sent to the customer. + */ + if ($created && $sendSetupLink) { + DB::afterCommit(static function () use ($email): void { + Password::sendResetLink(['email' => $email]); + }); + } + + return $user; + } + + private function findByEmail(string $email): ?User + { + return User::query() + ->whereRaw('LOWER(email) = ?', [$email]) + ->first(); + } + + private function displayName(?string $name, string $email): string + { + $name = trim((string) $name); + + if ($name !== '') { + return mb_substr($name, 0, 120); + } + + $localPart = strstr($email, '@', true) ?: 'SonarDesk Customer'; + $localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart)); + + return mb_substr( + $localPart !== '' + ? ucwords($localPart) + : 'SonarDesk Customer', + 0, + 120 + ); + } +} diff --git a/integration/sonardesk-legacy/app/Services/IndexNowService.php b/integration/sonardesk-legacy/app/Services/IndexNowService.php new file mode 100644 index 0000000..7c46357 --- /dev/null +++ b/integration/sonardesk-legacy/app/Services/IndexNowService.php @@ -0,0 +1,65 @@ + $urls */ + public function submit(array $urls): ?Response + { + if (! config('indexnow.enabled')) { + Log::info('IndexNow submission skipped: disabled.'); + return null; + } + + $key = trim((string) config('indexnow.key')); + if ($key === '') { + throw new InvalidArgumentException('INDEXNOW_KEY is not configured.'); + } + + $urls = collect($urls) + ->filter(fn ($url) => is_string($url) && filter_var($url, FILTER_VALIDATE_URL)) + ->unique() + ->values() + ->all(); + + if ($urls === []) { + throw new InvalidArgumentException('No valid URLs were supplied to IndexNow.'); + } + + $host = parse_url($urls[0], PHP_URL_HOST); + if (! is_string($host) || $host === '') { + throw new InvalidArgumentException('Could not determine the IndexNow host.'); + } + + foreach ($urls as $url) { + if (parse_url($url, PHP_URL_HOST) !== $host) { + throw new InvalidArgumentException('All IndexNow URLs must use the same host.'); + } + } + + $response = Http::asJson() + ->acceptJson() + ->timeout(10) + ->retry(2, 500) + ->post((string) config('indexnow.endpoint'), [ + 'host' => $host, + 'key' => $key, + 'keyLocation' => 'https://'.$host.'/'.$key.'.txt', + 'urlList' => $urls, + ]); + + if ($response->successful()) { + Log::info('IndexNow URLs submitted.', ['host' => $host, 'count' => count($urls), 'status' => $response->status()]); + } else { + Log::warning('IndexNow submission failed.', ['host' => $host, 'count' => count($urls), 'status' => $response->status(), 'body' => $response->body()]); + } + + return $response; + } +} diff --git a/integration/sonardesk-legacy/app/Services/Payments/CustomerAccountProvisioner.php b/integration/sonardesk-legacy/app/Services/Payments/CustomerAccountProvisioner.php new file mode 100644 index 0000000..f970ea9 --- /dev/null +++ b/integration/sonardesk-legacy/app/Services/Payments/CustomerAccountProvisioner.php @@ -0,0 +1,63 @@ +first(); + if ($user) { + return $user; + } + + $displayName = trim((string) $name); + if ($displayName === '') { + $displayName = Str::headline(Str::before($email, '@')) ?: 'SonarDesk Customer'; + } + + $user = User::firstOrCreate( + ['email' => $email], + [ + 'name' => $displayName, + // The customer never receives or knows this password. They choose their + // own password through Laravel's signed, single-use reset flow. + 'password' => Str::random(64), + // Access still requires possession of the mailbox because account setup + // is completed using the reset link sent to this address. + 'email_verified_at' => now(), + ], + ); + + if ($user->wasRecentlyCreated) { + DB::afterCommit(function () use ($email): void { + try { + $status = Password::sendResetLink(['email' => $email]); + if ($status !== Password::RESET_LINK_SENT) { + Log::warning('SonarDesk customer account created but setup email was not sent.', [ + 'email' => $email, + 'status' => $status, + ]); + } + } catch (\Throwable $exception) { + // A mail outage must never undo a paid licence. The customer can + // also use Forgot Password once mail is restored. + report($exception); + } + }); + } + + return $user; + } +} diff --git a/integration/sonardesk-legacy/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/integration/sonardesk-legacy/app/Services/Payments/LemonSqueezyWebhookVerifier.php new file mode 100755 index 0000000..356a3e5 --- /dev/null +++ b/integration/sonardesk-legacy/app/Services/Payments/LemonSqueezyWebhookVerifier.php @@ -0,0 +1,22 @@ +header('X-Signature'); + if ($secret === '' || $signature === '') { + throw new RuntimeException('Lemon Squeezy webhook verification is not configured.'); + } + $expected = hash_hmac('sha256', $rawBody, $secret); + if (! hash_equals($expected, $signature)) { + throw new RuntimeException('Lemon Squeezy webhook signature is invalid.'); + } + } +} diff --git a/integration/sonardesk-legacy/app/Services/Payments/WebhookVerifier.php b/integration/sonardesk-legacy/app/Services/Payments/WebhookVerifier.php new file mode 100755 index 0000000..984c28e --- /dev/null +++ b/integration/sonardesk-legacy/app/Services/Payments/WebhookVerifier.php @@ -0,0 +1,10 @@ + (bool) env('INDEXNOW_ENABLED', false), + 'key' => env('INDEXNOW_KEY'), + 'endpoint' => env('INDEXNOW_ENDPOINT', 'https://api.indexnow.org/indexnow'), +]; diff --git a/integration/sonardesk-legacy/config/sonardesk.php b/integration/sonardesk-legacy/config/sonardesk.php new file mode 100644 index 0000000..188be39 --- /dev/null +++ b/integration/sonardesk-legacy/config/sonardesk.php @@ -0,0 +1,35 @@ + env('SONARDESK_ENVIRONMENT', 'develop'), + 'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'), + 'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'), + 'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'), + 'default_edition' => env('SONARDESK_DEFAULT_EDITION', 'Professional'), + 'default_max_major_version' => (int) env('SONARDESK_DEFAULT_MAX_MAJOR_VERSION', 1), + 'activation_rate_limit' => env('SONARDESK_ACTIVATION_RATE_LIMIT', '12,1'), + 'purchase_url' => env('SONARDESK_PURCHASE_URL'), + 'portal_url' => env('SONARDESK_PORTAL_URL'), + 'support_url' => env('SONARDESK_SUPPORT_URL'), + + 'legal' => [ + 'controller_name' => env('SONARDESK_LEGAL_NAME', 'SonarDesk'), + 'privacy_email' => env('SONARDESK_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')), + 'support_email' => env('SONARDESK_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')), + 'country' => env('SONARDESK_LEGAL_COUNTRY', 'United Kingdom'), + ], + 'privacy' => [ + 'policy_version' => env('SONARDESK_PRIVACY_VERSION', '2026-09-09'), + 'cookie_version' => env('SONARDESK_COOKIE_VERSION', '1'), + 'support_retention_days' => (int) env('SONARDESK_SUPPORT_RETENTION_DAYS', 730), + ], + 'support' => [ + 'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))), + 'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), + ], + + + 'lemon_squeezy' => [ + 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), + ], +]; diff --git a/integration/sonardesk-legacy/database/factories/UserFactory.php b/integration/sonardesk-legacy/database/factories/UserFactory.php new file mode 100644 index 0000000..c411c07 --- /dev/null +++ b/integration/sonardesk-legacy/database/factories/UserFactory.php @@ -0,0 +1,34 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + 'is_admin' => false, + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_08_193343_create_sessions_table.php b/integration/sonardesk-legacy/database/migrations/2026_09_08_193343_create_sessions_table.php new file mode 100755 index 0000000..f60625b --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_08_193343_create_sessions_table.php @@ -0,0 +1,31 @@ +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('sessions'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/integration/sonardesk-legacy/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php new file mode 100755 index 0000000..babc7a3 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php @@ -0,0 +1,92 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_08_200758_create_cache_table.php b/integration/sonardesk-legacy/database/migrations/2026_09_08_200758_create_cache_table.php new file mode 100755 index 0000000..06dc7a5 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_08_200758_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php b/integration/sonardesk-legacy/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php new file mode 100755 index 0000000..b058933 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_08_205000_create_sonardesk_auth_tables.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('password_reset_tokens')) { + Schema::create('password_reset_tokens', function (Blueprint $table): void { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/integration/sonardesk-legacy/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/integration/sonardesk-legacy/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php b/integration/sonardesk-legacy/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php new file mode 100755 index 0000000..a6223e3 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->string('reference', 24)->unique(); + $table->string('name', 120); + $table->string('email', 254)->index(); + $table->string('category', 40)->default('general'); + $table->string('subject', 180); + $table->text('message'); + $table->string('status', 20)->default('new')->index(); + $table->string('priority', 20)->default('normal'); + $table->timestamp('privacy_notice_accepted_at'); + $table->timestamp('resolved_at')->nullable()->index(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('notifications')) { + Schema::create('notifications', function (Blueprint $table): void { + $table->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('support_tickets'); + Schema::dropIfExists('notifications'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_29_100000_create_cms_pages_table.php b/integration/sonardesk-legacy/database/migrations/2026_09_29_100000_create_cms_pages_table.php new file mode 100644 index 0000000..dee3e79 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_29_100000_create_cms_pages_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('body'); + $table->string('meta_title')->nullable(); + $table->string('meta_description', 320)->nullable(); + $table->boolean('is_published')->default(false)->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->timestamps(); + }); + } + + public function down(): void { Schema::dropIfExists('cms_pages'); } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php b/integration/sonardesk-legacy/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php new file mode 100644 index 0000000..e82cce2 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('version', 40); + $table->unsignedInteger('build_number')->default(1); + $table->string('channel', 20)->default('stable'); + $table->string('minimum_macos', 30)->nullable(); + $table->string('file_path')->nullable(); + $table->string('original_filename')->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + $table->string('sha256', 64)->nullable(); + $table->text('sparkle_signature')->nullable(); + $table->text('release_notes')->nullable(); + $table->boolean('is_published')->default(false)->index(); + $table->boolean('is_featured')->default(false)->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->unsignedBigInteger('download_count')->default(0); + $table->timestamps(); + $table->unique(['version', 'build_number', 'channel']); + }); + + Schema::create('sonardesk_site_settings', function (Blueprint $table): void { + $table->string('key')->primary(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sonardesk_site_settings'); + Schema::dropIfExists('sonardesk_releases'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php b/integration/sonardesk-legacy/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php new file mode 100644 index 0000000..5633841 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php @@ -0,0 +1,35 @@ +string('meta_keywords', 500)->nullable()->after('meta_description'); + $table->string('canonical_url', 2048)->nullable()->after('meta_keywords'); + $table->string('og_title')->nullable()->after('canonical_url'); + $table->string('og_description', 320)->nullable()->after('og_title'); + $table->string('og_image', 2048)->nullable()->after('og_description'); + $table->boolean('robots_index')->default(true)->after('og_image'); + $table->boolean('robots_follow')->default(true)->after('robots_index'); + $table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index(); + $table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap'); + $table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority'); + }); + } + + public function down(): void + { + Schema::table('cms_pages', function (Blueprint $table): void { + $table->dropIndex(['include_in_sitemap']); + $table->dropColumn([ + 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image', + 'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority', + 'sitemap_change_frequency', + ]); + }); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php b/integration/sonardesk-legacy/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php new file mode 100644 index 0000000..4763179 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php @@ -0,0 +1,22 @@ + $table->timestamp('disabled_at')->nullable()->index()->after('is_admin')); + } + } + + public function down(): void + { + if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at')); + } + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_10_02_100000_create_failed_jobs_table.php b/integration/sonardesk-legacy/database/migrations/2026_10_02_100000_create_failed_jobs_table.php new file mode 100644 index 0000000..d0cebcc --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_10_02_100000_create_failed_jobs_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/integration/sonardesk-legacy/database/migrations/2026_10_12_100000_create_download_products_table.php b/integration/sonardesk-legacy/database/migrations/2026_10_12_100000_create_download_products_table.php new file mode 100644 index 0000000..8368227 --- /dev/null +++ b/integration/sonardesk-legacy/database/migrations/2026_10_12_100000_create_download_products_table.php @@ -0,0 +1,18 @@ +id(); $table->string('name'); $table->text('description')->nullable(); + $table->string('version')->nullable(); $table->string('platform')->nullable(); + $table->string('file_path')->nullable(); $table->string('original_filename')->nullable(); + $table->unsignedBigInteger('file_size')->default(0); $table->unsignedBigInteger('download_count')->default(0); + $table->boolean('is_published')->default(false); $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('download_products'); } +}; diff --git a/integration/sonardesk-legacy/modules/Licensing/Config/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Config/config.php b/integration/sonardesk-legacy/modules/Licensing/Config/config.php new file mode 100644 index 0000000..3b94eda --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Config/config.php @@ -0,0 +1,8 @@ + 'Licensing', + 'features' => + [ + ], +]; diff --git a/integration/sonardesk-legacy/modules/Licensing/Console/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseDeliver.php b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseDeliver.php new file mode 100644 index 0000000..67e5050 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseDeliver.php @@ -0,0 +1,20 @@ +option('limit'), 100)); + $count = $delivery->deliverPending($limit); + $this->info("Delivered {$count} licence email(s)."); + return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseIssue.php b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseIssue.php new file mode 100755 index 0000000..12469cb --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseIssue.php @@ -0,0 +1,29 @@ +argument('product'))->firstOrFail(); + [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null); + if (! $key) { + $this->warn('Licence already existed; plaintext key is intentionally not recoverable.'); + + return self::FAILURE; + } $this->line('Licence ID: '.$l->id); + $this->line('Licence key: '.$key); + $this->warn('This is the only time the plaintext key is shown.'); + + return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseRevoke.php b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseRevoke.php new file mode 100755 index 0000000..ad34c16 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskLicenseRevoke.php @@ -0,0 +1,22 @@ +argument('id')); + $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]); + $this->info('Licence revoked.'); + + return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskProductCreate.php b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskProductCreate.php new file mode 100755 index 0000000..8622a85 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Console/SonarDeskProductCreate.php @@ -0,0 +1,21 @@ + $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]); + $this->info("Product {$p->code} ready."); + + return self::SUCCESS; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php new file mode 100755 index 0000000..babc7a3 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php @@ -0,0 +1,92 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/Seeders/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/integration/sonardesk-legacy/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php new file mode 100644 index 0000000..fbb0b58 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Database/factories/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Helpers/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Helpers/Helper.php b/integration/sonardesk-legacy/modules/Licensing/Helpers/Helper.php new file mode 100644 index 0000000..7607c60 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Helpers/Helper.php @@ -0,0 +1,12 @@ +validate([ + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); + + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); + } + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse + { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php b/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php new file mode 100755 index 0000000..61d9512 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php @@ -0,0 +1,191 @@ +getContent(); + + $verifier = match ($provider) { + 'paddle' => app(PaddleWebhookVerifier::class), + 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class), + default => null, + }; + + if (! $verifier) { + return response()->json([ + 'message' => 'Unknown webhook provider.', + ], 404); + } + + try { + $verifier->verify($request, $raw); + } catch (Throwable $e) { + report($e); + + return response()->json([ + 'message' => 'Invalid webhook signature.', + ], 400); + } + + try { + $payload = json_decode( + $raw, + true, + flags: JSON_THROW_ON_ERROR + ); + } catch (Throwable) { + return response()->json([ + 'message' => 'Invalid JSON.', + ], 400); + } + + $externalID = (string) ( + $provider === 'paddle' + ? ($payload['event_id'] ?? '') + : ($payload['meta']['event_name'] ?? '') + .':' + .($payload['data']['id'] ?? '') + ); + + $eventType = (string) ( + $provider === 'paddle' + ? ($payload['event_type'] ?? 'unknown') + : ($payload['meta']['event_name'] ?? 'unknown') + ); + + if ($externalID === '') { + return response()->json([ + 'message' => 'Webhook event has no stable identifier.', + ], 400); + } + + $payloadHash = hash('sha256', $raw); + + try { + /* + * Paddle can deliver several related events concurrently. + * + * Laravel retries this complete transaction when MySQL reports + * a deadlock. Five attempts gives short-lived lock contention + * enough opportunity to resolve without acknowledging an event + * that was not actually processed. + */ + DB::transaction( + function () use ( + $provider, + $externalID, + $eventType, + $payload, + $payloadHash, + $provisioner + ): void { + /* + * Insert the event if this is its first delivery. + * + * Once present, lock the row so duplicate deliveries of + * the same event cannot provision concurrently. + */ + LicenseWebhookEvent::firstOrCreate( + [ + 'provider' => $provider, + 'external_event_id' => $externalID, + ], + [ + 'event_type' => $eventType, + 'payload_sha256' => $payloadHash, + 'payload' => $payload, + ] + ); + + $event = LicenseWebhookEvent::query() + ->where('provider', $provider) + ->where('external_event_id', $externalID) + ->lockForUpdate() + ->firstOrFail(); + + if ($event->processed_at) { + return; + } + + if (! hash_equals( + (string) $event->payload_sha256, + $payloadHash + )) { + throw new RuntimeException( + 'Webhook ID was reused with a different payload.' + ); + } + + try { + $provisioner->provision( + $provider, + $payload + ); + + $event->processed_at = now(); + $event->processing_error = null; + $event->save(); + } catch (Throwable $e) { + /* + * This assignment is useful when the surrounding + * transaction eventually succeeds. If processing + * throws, the transaction itself is rolled back and + * Paddle will retry the event. + */ + $event->processing_error = mb_substr( + $e->getMessage(), + 0, + 4000 + ); + + $event->save(); + + throw $e; + } + }, + 5 + ); + } catch (Throwable $e) { + report($e); + return response()->json([ + 'message' => 'Webhook could not be processed.', + ], 500); + } + + /* + * Delivery deliberately happens outside the webhook transaction. + * + * A temporary SMTP failure must not undo a successfully processed + * payment event. The outbox retains anything that still needs + * delivery. + */ + try { + $delivery->deliverPending(10); + } catch (Throwable $e) { + report($e); + } + + return response()->json([ + 'ok' => true, + ]); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/LicensingController.php b/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/LicensingController.php new file mode 100644 index 0000000..8ece907 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Http/Controllers/LicensingController.php @@ -0,0 +1,79 @@ +whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (! $item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (! $out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $out->update([ + 'recipient_email' => strtolower($request->user()->email), + 'delivered_at' => null, + ]); + $this->delivery->deliverPending(1); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Http/Middleware/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Http/Middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Http/Requests/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/License.php b/integration/sonardesk-legacy/modules/Licensing/Models/License.php new file mode 100755 index 0000000..86e5c6a --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Models/License.php @@ -0,0 +1,36 @@ + 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; + + public function product(): BelongsTo + { + return $this->belongsTo(LicenseProduct::class, 'license_product_id'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenseActivation::class); + } + + public function getIsUsableAttribute(): bool + { + return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/LicenseActivation.php b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseActivation.php new file mode 100755 index 0000000..fb454a0 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseActivation.php @@ -0,0 +1,14 @@ + 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime']; +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/LicenseDeliveryOutbox.php b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseDeliveryOutbox.php new file mode 100755 index 0000000..89d1d5f --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseDeliveryOutbox.php @@ -0,0 +1,20 @@ + 'datetime']; + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/LicenseProduct.php b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseProduct.php new file mode 100755 index 0000000..eb8a4cc --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseProduct.php @@ -0,0 +1,12 @@ + 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer']; +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Models/LicenseWebhookEvent.php b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseWebhookEvent.php new file mode 100755 index 0000000..a8cc130 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Models/LicenseWebhookEvent.php @@ -0,0 +1,12 @@ + 'array', 'processed_at' => 'datetime']; +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Providers/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Providers/LicensingServiceProvider.php b/integration/sonardesk-legacy/modules/Licensing/Providers/LicensingServiceProvider.php new file mode 100644 index 0000000..c514a59 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Providers/LicensingServiceProvider.php @@ -0,0 +1,171 @@ + Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => + Limit::perHour(5)->by($request->ip()) + ); + + RateLimiter::for('license-activation', function (Request $request): array { + return [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Providers/RouteServiceProvider.php b/integration/sonardesk-legacy/modules/Licensing/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c691ba8 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/api.php')); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/assets/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/assets/js/app.js b/integration/sonardesk-legacy/modules/Licensing/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/assets/sass/app.scss b/integration/sonardesk-legacy/modules/Licensing/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/lang/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/views/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/views/index.blade.php b/integration/sonardesk-legacy/modules/Licensing/Resources/views/index.blade.php new file mode 100644 index 0000000..4277b06 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('licensing::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('licensing.name') !!} +

+@endsection diff --git a/integration/sonardesk-legacy/modules/Licensing/Resources/views/layouts/master.blade.php b/integration/sonardesk-legacy/modules/Licensing/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..1e51f1c --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Licensing + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/integration/sonardesk-legacy/modules/Licensing/Routes/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Routes/api.php b/integration/sonardesk-legacy/modules/Licensing/Routes/api.php new file mode 100644 index 0000000..a923714 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Routes/api.php @@ -0,0 +1,12 @@ +group(function (): void { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/integration/sonardesk-legacy/modules/Licensing/Routes/web.php b/integration/sonardesk-legacy/modules/Licensing/Routes/web.php new file mode 100644 index 0000000..cfe4d88 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Routes/web.php @@ -0,0 +1,13 @@ +prefix('portal')->name('portal.')->group(function (): void { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); +}); diff --git a/integration/sonardesk-legacy/modules/Licensing/Services/LicenseDeliveryService.php b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseDeliveryService.php new file mode 100755 index 0000000..053600d --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseDeliveryService.php @@ -0,0 +1,54 @@ +with('license') + ->whereNull('delivered_at') + ->whereNotNull('recipient_email') + ->where('recipient_email', '!=', '') + ->oldest() + ->limit(max(1, min($limit, 100))) + ->get(); + + foreach ($pending as $outbox) { + $license = $outbox->license; + if (! $license) { + continue; + } + + $plain = Crypt::decryptString($outbox->encrypted_license_key); + $recipient = strtolower(trim((string) $outbox->recipient_email)); + if ($recipient === '') { + continue; + } + + $data = [ + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'licenseKey' => $plain, + 'expiresAt' => $license->expires_at, + 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'), + 'downloadUrl' => route('release'), + ]; + + Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void { + $message->to($recipient)->subject('Your SonarDesk licence key'); + }); + + $outbox->update(['delivered_at' => now()]); + $delivered++; + } + + return $delivered; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Services/LicenseIssuer.php b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseIssuer.php new file mode 100755 index 0000000..7171dd8 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseIssuer.php @@ -0,0 +1,46 @@ +where('external_order_id', $orderID)->first(); + if ($existing) { + return [$existing, null]; + } + } + $plain = LicenseKey::generate(); + $license = License::create([ + 'license_product_id' => $product->id, + 'license_key_hash' => LicenseKey::hash($plain), + 'license_key_last4' => substr(LicenseKey::normalize($plain), -4), + 'customer_name' => $name, + 'customer_email' => $email ? strtolower(trim($email)) : null, + 'edition' => $product->edition, + 'activation_limit' => $product->activation_limit, + 'max_major_version' => $product->max_major_version, + 'payment_provider' => $provider, + 'external_order_id' => $orderID, + 'external_customer_id' => $customerID, + ]); + if ($queueDelivery) { + LicenseDeliveryOutbox::updateOrCreate( + ['license_id' => $license->id], + ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] + ); + } + + return [$license, $plain]; + }); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Services/LicenseKey.php b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseKey.php new file mode 100755 index 0000000..4e2501f --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseKey.php @@ -0,0 +1,27 @@ +privateSeed(); + $pair = sodium_crypto_sign_seed_keypair($seed); + $secret = sodium_crypto_sign_secretkey($pair); + + $payload = [ + 'customerEmail' => $license->customer_email, + 'customerName' => $license->customer_name, + 'edition' => $license->edition, + 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'installationID' => strtolower($installationID), + 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'), + 'licenseID' => strtolower((string) $license->id), + 'maxMajorVersion' => $license->max_major_version, + ]; + ksort($payload, SORT_STRING); + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); + $sig = sodium_crypto_sign_detached($json, $secret); + + return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); + } + + public function publicKeyBase64(): string + { + $pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); + + return base64_encode(sodium_crypto_sign_publickey($pair)); + } + + private function privateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + $decoded = base64_decode(trim((string) file_get_contents($path)), true); + if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + return $decoded; + } + + private static function b64url(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Services/LicenseTokenVerifier.php b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseTokenVerifier.php new file mode 100755 index 0000000..052f633 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Services/LicenseTokenVerifier.php @@ -0,0 +1,58 @@ +decode($parts[1]); + $signature = $this->decode($parts[2]); + // Derive the verification key from the same private seed used by LicenseSigner. + // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift: + // activation would sign a perfectly valid token with the new private key, then + // /validate would reject that same token using a stale public key. + $public = $this->publicKeyFromPrivateSeed(); + if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { + throw new RuntimeException('Licence token signature is invalid.'); + } + $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + + return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); + } + + private function publicKeyFromPrivateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + + $seed = base64_decode(trim((string) file_get_contents($path)), true); + if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + $pair = sodium_crypto_sign_seed_keypair($seed); + + return sodium_crypto_sign_publickey($pair); + } + + private function decode(string $value): string + { + $value = strtr($value, '-_', '+/'); + $value .= str_repeat('=', (4 - strlen($value) % 4) % 4); + $out = base64_decode($value, true); + if ($out === false) { + throw new RuntimeException('Invalid Base64URL value.'); + } + + return $out; + } +} diff --git a/integration/sonardesk-legacy/modules/Licensing/Support/.gitkeep b/integration/sonardesk-legacy/modules/Licensing/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Licensing/Support/ModuleInfo.php b/integration/sonardesk-legacy/modules/Licensing/Support/ModuleInfo.php new file mode 100644 index 0000000..28b9983 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Licensing/Support/ModuleInfo.php @@ -0,0 +1,26 @@ + 'Paddle', + 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), + 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), + 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), + 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), + 'product_id' => env('PADDLE_PRODUCT_ID'), + 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), + 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), + 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), + 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), +]; diff --git a/integration/sonardesk-legacy/modules/Paddle/Console/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/Migrations/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/integration/sonardesk-legacy/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php b/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php new file mode 100755 index 0000000..203ffe5 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php @@ -0,0 +1,29 @@ + 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_annual_price_id')], + ]; + + foreach ($products as $data) { + if (! $data['external_product_id'] || ! $data['external_price_id']) { + continue; + } + LicenseProduct::updateOrCreate( + ['code' => $data['code']], + $data + ['payment_provider' => 'paddle', 'max_major_version' => config('sonardesk.default_max_major_version'), 'active' => true] + ); + } + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php b/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php new file mode 100644 index 0000000..b413999 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Database/factories/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Helpers/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Helpers/Helper.php b/integration/sonardesk-legacy/modules/Paddle/Helpers/Helper.php new file mode 100644 index 0000000..4e6aad8 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Helpers/Helper.php @@ -0,0 +1,12 @@ + ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], + 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£39.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], + 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£8.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], + 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£79.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], + ]; + + foreach ($plans as $plan) { + if (trim((string) $plan['price_id']) === '') { + throw new RuntimeException('One or more Paddle price IDs are not configured.'); + } + } + + return view('checkout.buy', [ + 'plans' => $plans, + 'paddleToken' => $token, + 'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'), + ]); + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Http/Controllers/PaddleController.php b/integration/sonardesk-legacy/modules/Paddle/Http/Controllers/PaddleController.php new file mode 100644 index 0000000..cbb633c --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Http/Controllers/PaddleController.php @@ -0,0 +1,79 @@ +registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Providers/RouteServiceProvider.php b/integration/sonardesk-legacy/modules/Paddle/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..3ef031c --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Paddle', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Paddle', '/Routes/api.php')); + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/assets/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/assets/js/app.js b/integration/sonardesk-legacy/modules/Paddle/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/assets/sass/app.scss b/integration/sonardesk-legacy/modules/Paddle/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/lang/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/views/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/views/index.blade.php b/integration/sonardesk-legacy/modules/Paddle/Resources/views/index.blade.php new file mode 100644 index 0000000..658045d --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('paddle::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('paddle.name') !!} +

+@endsection diff --git a/integration/sonardesk-legacy/modules/Paddle/Resources/views/layouts/master.blade.php b/integration/sonardesk-legacy/modules/Paddle/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..b42fb10 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Paddle + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/integration/sonardesk-legacy/modules/Paddle/Routes/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Routes/api.php b/integration/sonardesk-legacy/modules/Paddle/Routes/api.php new file mode 100644 index 0000000..dbc50ef --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Routes/api.php @@ -0,0 +1,18 @@ +get('/paddle', function (Request $request) { + return $request->user(); +}); \ No newline at end of file diff --git a/integration/sonardesk-legacy/modules/Paddle/Routes/web.php b/integration/sonardesk-legacy/modules/Paddle/Routes/web.php new file mode 100644 index 0000000..a623b34 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Routes/web.php @@ -0,0 +1,6 @@ +name('buy'); diff --git a/integration/sonardesk-legacy/modules/Paddle/Services/PaddleClient.php b/integration/sonardesk-legacy/modules/Paddle/Services/PaddleClient.php new file mode 100755 index 0000000..3585df4 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Services/PaddleClient.php @@ -0,0 +1,170 @@ +request()->get('/customers/'.$customerID); + + if (! $response->successful()) { + return null; + } + + $data = $response->json('data'); + + return is_array($data) ? $data : null; + } + + public function subscription(string $subscriptionID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request() + ->get('/subscriptions/'.$subscriptionID); + + return $this->subscriptionData( + $response, + 'Paddle could not retrieve the subscription.' + ); + } + + public function updateSubscriptionPrice( + string $subscriptionID, + string $priceID + ): array { + $this->requireWritableConfiguration(); + + /* + * Paddle restricts subscription item changes while a subscription + * is trialing. Existing item quantities may only be updated using + * do_not_bill during the trial. + * + * Once the subscription is outside its trial we can use normal + * immediate proration. + */ + $subscription = $this->subscription($subscriptionID); + + $status = (string) ($subscription['status'] ?? ''); + + $prorationBillingMode = $status === 'trialing' + ? 'do_not_bill' + : 'prorated_immediately'; + + /* + * Preserve the existing subscription item and replace its price. + * + * Paddle treats the submitted items array as the desired + * subscription items, so quantity is retained where possible. + */ + $existingItems = is_array($subscription['items'] ?? null) + ? $subscription['items'] + : []; + + $existingItem = $existingItems[0] ?? []; + + $quantity = max( + 1, + (int) ($existingItem['quantity'] ?? 1) + ); + + $response = $this->request()->patch( + '/subscriptions/'.$subscriptionID, + [ + 'proration_billing_mode' => $prorationBillingMode, + 'items' => [ + [ + 'price_id' => $priceID, + 'quantity' => $quantity, + ], + ], + ] + ); + + return $this->subscriptionData( + $response, + 'Paddle could not update the subscription.' + ); + } + + public function cancelSubscriptionImmediately( + string $subscriptionID + ): array { + $this->requireWritableConfiguration(); + + $response = $this->request()->post( + '/subscriptions/'.$subscriptionID.'/cancel', + [ + 'effective_from' => 'immediately', + ] + ); + + return $this->subscriptionData( + $response, + 'Paddle could not cancel the subscription.' + ); + } + + private function request(): PendingRequest + { + return Http::baseUrl( + (string) config('paddle.api_url') + ) + ->withToken( + (string) config('paddle.api_key') + ) + ->acceptJson() + ->timeout(15); + } + + private function requireWritableConfiguration(): void + { + if ( + trim( + (string) config('paddle.api_key') + ) === '' + ) { + throw new RuntimeException( + 'Paddle subscription management is not configured.' + ); + } + } + + private function subscriptionData( + Response $response, + string $fallback + ): array { + if (! $response->successful()) { + $detail = trim( + (string) ( + $response->json('error.detail') + ?? '' + ) + ); + + throw new RuntimeException( + $detail !== '' + ? $detail + : $fallback + ); + } + + $data = $response->json('data'); + + if (! is_array($data)) { + throw new RuntimeException($fallback); + } + + return $data; + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Services/PaddleWebhookVerifier.php b/integration/sonardesk-legacy/modules/Paddle/Services/PaddleWebhookVerifier.php new file mode 100755 index 0000000..5ba1ba2 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Services/PaddleWebhookVerifier.php @@ -0,0 +1,108 @@ +header('Paddle-Signature')); + + if ($secret === '') { + throw new RuntimeException('Paddle webhook secret is not configured.'); + } + + if ($header === '') { + throw new RuntimeException('Paddle-Signature header is missing.'); + } + + $parts = []; + + foreach (explode(';', $header) as $part) { + [$key, $value] = array_pad( + explode('=', trim($part), 2), + 2, + null + ); + + if ($key !== null && $value !== null) { + $parts[$key][] = $value; + } + } + + $timestamp = isset($parts['ts'][0]) + ? (int) $parts['ts'][0] + : 0; + + $signatures = $parts['h1'] ?? []; + + if ($timestamp === 0) { + throw new RuntimeException( + 'Paddle webhook signature has no timestamp.' + ); + } + + if ($signatures === []) { + throw new RuntimeException( + 'Paddle webhook signature has no h1 signature.' + ); + } + + $tolerance = max( + 1, + (int) config('paddle.timestamp_tolerance', 300) + ); + + $age = abs(time() - $timestamp); + + if ($age > $tolerance) { + throw new RuntimeException( + "Paddle webhook timestamp is outside the allowed tolerance ({$age}s)." + ); + } + + $signedPayload = $timestamp . ':' . $rawBody; + + $expected = hash_hmac( + 'sha256', + $signedPayload, + $secret + ); + + Log::debug('Paddle webhook signature diagnostics', [ + 'timestamp' => $timestamp, + 'age_seconds' => $age, + 'body_length' => strlen($rawBody), + 'body_sha256' => hash('sha256', $rawBody), + + // Safe diagnostics — do NOT log the secret itself. + 'secret_prefix' => substr($secret, 0, 11), + 'secret_length' => strlen($secret), + + 'received_signature_prefixes' => array_map( + static fn (string $signature): string => + substr($signature, 0, 12), + $signatures + ), + + 'expected_signature_prefix' => substr($expected, 0, 12), + ]); + + foreach ($signatures as $signature) { + if (hash_equals($expected, $signature)) { + return; + } + } + + throw new RuntimeException( + 'Paddle webhook signature is invalid.' + ); + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Services/PurchaseProvisioner.php b/integration/sonardesk-legacy/modules/Paddle/Services/PurchaseProvisioner.php new file mode 100755 index 0000000..2209f9c --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Services/PurchaseProvisioner.php @@ -0,0 +1,796 @@ +provisionPaddle($event); + + return; + } + + if ($provider === 'lemon-squeezy') { + $this->provisionLemonSqueezy($event); + + return; + } + + throw new RuntimeException( + 'Unsupported payment provider.' + ); + } + + private function provisionPaddle(array $event): void + { + $type = (string) ($event['event_type'] ?? ''); + + $data = is_array($event['data'] ?? null) + ? $event['data'] + : []; + + if (str_starts_with($type, 'subscription.')) { + $this->syncPaddleSubscription( + $type, + $data + ); + + return; + } + + if ($type === 'transaction.completed') { + $this->syncPaddleTransaction($data); + + return; + } + + if ($type === 'transaction.refunded') { + $transactionID = $this->nullableString( + $data['id'] ?? null + ); + + if ($transactionID !== null) { + License::query() + ->where('payment_provider', 'paddle') + ->where( + 'external_order_id', + $transactionID + ) + ->whereNull('external_subscription_id') + ->whereNull('revoked_at') + ->update([ + 'revoked_at' => now(), + 'revoke_reason' => 'Payment refunded', + ]); + } + } + } + + private function syncPaddleSubscription( + string $type, + array $data + ): void { + $subscriptionID = $this->nullableString( + $data['id'] ?? null + ); + + if ($subscriptionID === null) { + throw new RuntimeException( + 'Paddle subscription webhook has no subscription ID.' + ); + } + + /* + * Multiple Paddle events for the same subscription can arrive + * simultaneously. + * + * The unique index protects database integrity, but by itself it + * cannot stop two workers from both observing "no licence" before + * either worker inserts the subscription ID. + * + * A MySQL advisory lock serialises provisioning for one Paddle + * subscription while still allowing unrelated subscriptions to be + * processed concurrently. + */ + $lockName = 'sonardesk:paddle:subscription:' + .$subscriptionID; + + $this->withMysqlLock( + $lockName, + function () use ( + $type, + $data, + $subscriptionID + ): void { + $this->syncLockedPaddleSubscription( + $type, + $data, + $subscriptionID + ); + } + ); + } + + private function syncLockedPaddleSubscription( + string $type, + array $data, + string $subscriptionID + ): void { + $transactionID = $this->nullableString( + $data['transaction_id'] ?? null + ); + + $status = (string) ($data['status'] ?? ''); + + $items = is_array($data['items'] ?? null) + ? $data['items'] + : []; + + $item = $this->firstRecurringItem($items); + + $price = is_array($item['price'] ?? null) + ? $item['price'] + : []; + + $priceID = (string) ( + $price['id'] + ?? $item['price_id'] + ?? '' + ); + + $productID = (string) ( + $price['product_id'] + ?? $item['product_id'] + ?? '' + ); + + /* + * The Paddle subscription ID is the authoritative identity for a + * recurring licence. + * + * lockForUpdate protects an existing licence while we mutate it. + * Creation of a missing licence is serialised by the advisory lock + * acquired by syncPaddleSubscription(). + */ + $license = License::query() + ->where('payment_provider', 'paddle') + ->where( + 'external_subscription_id', + $subscriptionID + ) + ->lockForUpdate() + ->first(); + + if (! $license) { + $product = $this->mappedProduct( + 'paddle', + $priceID, + $productID + ); + + $custom = is_array($data['custom_data'] ?? null) + ? $data['custom_data'] + : []; + + $customerID = $this->nullableString( + $data['customer_id'] ?? null + ); + + $customer = $this->paddle->customer( + $customerID + ) ?? []; + + [$license] = $this->issuer->issue( + $product, + $this->nullableString( + $custom['customer_name'] + ?? $custom['name'] + ?? $customer['name'] + ?? null + ), + $this->nullableString( + $custom['customer_email'] + ?? $custom['email'] + ?? $customer['email'] + ?? null + ), + 'paddle', + $transactionID, + $customerID, + true, + ); + + /* + * LicenseIssuer may return an existing licence if the + * transaction ID has already been seen. + * + * Re-read it with a row lock before attaching the Paddle + * subscription. + */ + $license = License::query() + ->whereKey($license->getKey()) + ->lockForUpdate() + ->firstOrFail(); + + /* + * Do not attach this subscription to a licence already owned + * by a different subscription. + */ + if ( + $license->external_subscription_id !== null + && $license->external_subscription_id + !== $subscriptionID + ) { + throw new RuntimeException( + 'Paddle transaction resolved to a licence ' + .'belonging to another subscription.' + ); + } + + $license->external_subscription_id = + $subscriptionID; + } elseif ( + $priceID !== '' + && $priceID !== $license->external_price_id + ) { + /* + * Upgrades and downgrades modify the entitlement attached to + * the existing licence key rather than issuing another key. + */ + $product = $this->mappedProduct( + 'paddle', + $priceID, + $productID + ); + + $license->license_product_id = $product->id; + $license->edition = $product->edition; + $license->activation_limit = + $product->activation_limit; + $license->max_major_version = + $product->max_major_version; + } + + $periodEnd = + $data['current_billing_period']['ends_at'] + ?? null; + + $scheduled = $data['scheduled_change'] ?? null; + + $cancelAt = is_array($scheduled) + && ($scheduled['action'] ?? null) === 'cancel' + ? ($scheduled['effective_at'] ?? null) + : null; + + $license->external_customer_id = + $this->nullableString( + $data['customer_id'] ?? null + ) + ?? $license->external_customer_id; + + $this->backfillPaddleCustomer( + $license, + $data + ); + + /* + * Account provisioning is expected to be idempotent. + */ + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); + + if ($priceID !== '') { + $license->external_price_id = $priceID; + } + + if ( + $transactionID !== null + && ! $license->external_order_id + ) { + $license->external_order_id = + $transactionID; + } + + if ($status !== '') { + $license->subscription_status = $status; + } + + if ($periodEnd) { + $license->current_period_ends_at = + Carbon::parse($periodEnd); + } + + $license->cancel_scheduled_at = $cancelAt + ? Carbon::parse($cancelAt) + : null; + + /* + * Subscription entitlement. + */ + if (in_array( + $status, + ['active', 'trialing'], + true + )) { + $license->revoked_at = null; + $license->revoke_reason = null; + + $license->expires_at = $periodEnd + ? Carbon::parse($periodEnd) + : null; + } elseif ($status === 'past_due') { + /* + * A failed renewal does not immediately destroy the + * entitlement. Preserve Paddle's current billing period when + * available and otherwise provide the existing grace period. + */ + $license->expires_at = $periodEnd + ? Carbon::parse($periodEnd) + : ( + $license->expires_at + ?? now()->addDays(7) + ); + } elseif (in_array( + $status, + ['canceled', 'paused'], + true + )) { + $license->expires_at = now(); + $license->revoked_at = now(); + + $license->revoke_reason = + $status === 'paused' + ? 'Subscription paused' + : 'Subscription canceled'; + } + + $license->save(); + } + + private function syncPaddleTransaction( + array $data + ): void { + $subscriptionID = $this->nullableString( + $data['subscription_id'] ?? null + ); + + $transactionID = $this->nullableString( + $data['id'] ?? null + ); + + /* + * A recurring transaction belongs to its subscription. + * + * Serialise this operation using the same lock as subscription + * provisioning. This prevents transaction.completed from updating + * the licence while a subscription event is still creating or + * modifying it. + */ + if ($subscriptionID !== null) { + $lockName = 'sonardesk:paddle:subscription:' + .$subscriptionID; + + $this->withMysqlLock( + $lockName, + function () use ( + $subscriptionID, + $transactionID + ): void { + if ($transactionID === null) { + return; + } + + $license = License::query() + ->where( + 'payment_provider', + 'paddle' + ) + ->where( + 'external_subscription_id', + $subscriptionID + ) + ->lockForUpdate() + ->first(); + + /* + * transaction.completed may legitimately arrive before + * subscription.created. In that case there is nothing + * to update yet. The subscription event will create the + * licence and Paddle's transaction_id can populate the + * order ID. + */ + if (! $license) { + return; + } + + if (! $license->external_order_id) { + $license->external_order_id = + $transactionID; + + $license->save(); + } + } + ); + + return; + } + + /* + * No subscription ID means this may be a one-time purchase. + */ + $items = is_array($data['items'] ?? null) + ? $data['items'] + : []; + + $item = $items[0] ?? []; + + $price = is_array($item['price'] ?? null) + ? $item['price'] + : []; + + $priceID = (string) ( + $price['id'] + ?? $item['price_id'] + ?? '' + ); + + $productID = (string) ( + $price['product_id'] + ?? $item['product_id'] + ?? '' + ); + + $product = $this->mappedProduct( + 'paddle', + $priceID, + $productID + ); + + $custom = is_array($data['custom_data'] ?? null) + ? $data['custom_data'] + : []; + + $customerID = $this->nullableString( + $data['customer_id'] ?? null + ); + + $customer = $this->paddle->customer( + $customerID + ) ?? []; + + [$license] = $this->issuer->issue( + $product, + $this->nullableString( + $custom['customer_name'] + ?? $custom['name'] + ?? $customer['name'] + ?? null + ), + $this->nullableString( + $custom['customer_email'] + ?? $custom['email'] + ?? $customer['email'] + ?? null + ), + 'paddle', + $transactionID, + $customerID, + true, + ); + + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); + } + + private function provisionLemonSqueezy( + array $event + ): void { + $type = (string) ( + $event['meta']['event_name'] + ?? '' + ); + + $attributes = is_array( + $event['data']['attributes'] ?? null + ) + ? $event['data']['attributes'] + : []; + + $order = (string) ( + $event['data']['id'] + ?? $attributes['order_id'] + ?? '' + ); + + if (in_array( + $type, + [ + 'order_refunded', + 'subscription_expired', + ], + true + )) { + License::query() + ->where( + 'payment_provider', + 'lemon-squeezy' + ) + ->where('external_order_id', $order) + ->whereNull('revoked_at') + ->update([ + 'revoked_at' => now(), + 'revoke_reason' => + 'Payment refunded or subscription expired', + ]); + + return; + } + + if (! in_array( + $type, + [ + 'order_created', + 'subscription_created', + ], + true + )) { + return; + } + + $product = $this->mappedProduct( + 'lemon-squeezy', + (string) ( + $attributes['variant_id'] + ?? '' + ), + (string) ( + $attributes['product_id'] + ?? '' + ), + ); + + [$license] = $this->issuer->issue( + $product, + $this->nullableString( + $attributes['user_name'] ?? null + ), + $this->nullableString( + $attributes['user_email'] ?? null + ), + 'lemon-squeezy', + $order, + $this->nullableString( + $attributes['customer_id'] ?? null + ), + true, + ); + + $this->accounts->ensure( + $license->customer_email, + $license->customer_name + ); + } + + private function backfillPaddleCustomer( + License $license, + array $data + ): void { + $custom = is_array( + $data['custom_data'] ?? null + ) + ? $data['custom_data'] + : []; + + $customerID = $this->nullableString( + $data['customer_id'] + ?? $license->external_customer_id + ); + + $customer = $this->paddle->customer( + $customerID + ) ?? []; + + $name = $this->nullableString( + $custom['customer_name'] + ?? $custom['name'] + ?? $customer['name'] + ?? null + ); + + $email = $this->nullableString( + $custom['customer_email'] + ?? $custom['email'] + ?? $customer['email'] + ?? null + ); + + if (! $license->customer_name && $name) { + $license->customer_name = $name; + } + + if (! $license->customer_email && $email) { + $email = strtolower($email); + + $license->customer_email = $email; + + LicenseDeliveryOutbox::query() + ->where( + 'license_id', + $license->id + ) + ->whereNull('delivered_at') + ->update([ + 'recipient_email' => $email, + ]); + } + } + + private function mappedProduct( + string $provider, + string $priceID, + string $productID + ): LicenseProduct { + /* + * Price IDs are authoritative because multiple billing intervals + * can belong to the same Paddle product. + */ + if ($priceID !== '') { + $product = LicenseProduct::query() + ->where( + 'payment_provider', + $provider + ) + ->where( + 'external_price_id', + $priceID + ) + ->where('active', true) + ->first(); + } else { + $product = LicenseProduct::query() + ->where( + 'payment_provider', + $provider + ) + ->where( + 'external_product_id', + $productID + ) + ->where('active', true) + ->first(); + } + + if (! $product) { + throw new RuntimeException( + "No active licence product maps to {$provider} " + ."price {$priceID} / product {$productID}." + ); + } + + return $product; + } + + private function firstRecurringItem( + array $items + ): array { + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + if (($item['recurring'] ?? true) === true) { + return $item; + } + } + + $first = $items[0] ?? []; + + return is_array($first) + ? $first + : []; + } + + private function nullableString( + mixed $value + ): ?string { + if (! is_scalar($value)) { + return null; + } + + $value = trim((string) $value); + + return $value === '' + ? null + : $value; + } + + /** + * Execute a callback while holding a MySQL advisory lock. + * + * The lock is connection-scoped, so it must always be explicitly + * released in finally. A short timeout is sufficient because Paddle + * will retry failed webhook deliveries and the controller also retries + * database deadlocks. + */ + private function withMysqlLock( + string $name, + callable $callback + ): mixed { + /* + * MySQL advisory lock names have a finite length. Hashing also + * prevents provider supplied identifiers from creating unusual + * lock names. + */ + /* + * MySQL advisory locks are only available when using MySQL/MariaDB. + * + * Tests use SQLite in-memory, where GET_LOCK() and RELEASE_LOCK() + * do not exist. + */ + if (DB::connection()->getDriverName() !== 'mysql') { + return $callback(); + } + + $lockName = 'sd:'.substr(hash('sha256', $name), 0, 61); + + $row = DB::selectOne( + 'SELECT GET_LOCK(?, ?) AS acquired', + [ + $lockName, + 10, + ] + ); + + $acquired = (int) ( + $row->acquired + ?? 0 + ); + + if ($acquired !== 1) { + throw new RuntimeException( + 'Could not acquire Paddle subscription lock.' + ); + } + + try { + return $callback(); + } finally { + try { + DB::selectOne( + 'SELECT RELEASE_LOCK(?) AS released', + [$lockName] + ); + } catch (Throwable $e) { + /* + * Do not hide the original processing exception merely + * because releasing an advisory lock failed. MySQL also + * releases connection-owned locks when the connection + * closes. + */ + report($e); + } + } + } +} diff --git a/integration/sonardesk-legacy/modules/Paddle/Support/.gitkeep b/integration/sonardesk-legacy/modules/Paddle/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/modules/Paddle/Support/ModuleInfo.php b/integration/sonardesk-legacy/modules/Paddle/Support/ModuleInfo.php new file mode 100644 index 0000000..a64f0c0 --- /dev/null +++ b/integration/sonardesk-legacy/modules/Paddle/Support/ModuleInfo.php @@ -0,0 +1,26 @@ +
CMS

{{ $page->exists ? 'Edit page' : 'New page' }}

Back to pages +
@csrf @if($page->exists) @method('PUT') @endif + + + + +
+
Search appearance

Page SEO

Override global defaults only where this page needs specific search or social metadata.

Global SEO settings
+
+ + +
+ +
+
+ +
+ +
@if($page->exists && $page->is_published)View page@endif
+
+@if($page->exists)
@csrf @method('DELETE')
@endif +@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/cms/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/cms/index.blade.php new file mode 100644 index 0000000..52037c8 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/cms/index.blade.php @@ -0,0 +1,9 @@ +@extends('layouts.app') +@section('content') +
CMS

Content pages

Manage SonarDesk website content.

New page
+
{{ $pages->links() }}
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/download-products/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/download-products/index.blade.php new file mode 100644 index 0000000..6740f7f --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/download-products/index.blade.php @@ -0,0 +1,15 @@ +@extends('layouts.app', ['title' => 'Download products — Admin']) +@section('content') +
+
Distribution / Product management

Download products

Manage installers, releases and publication status in one place.

+ @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())@endif +
Total products{{ $products->count() }}
Published{{ $products->where('is_published', true)->count() }}
Drafts{{ $products->where('is_published', false)->count() }}
Total downloads{{ number_format($products->sum('download_count')) }}
+

Product catalogue

Publish or unpublish products instantly, or edit their details.

+ @forelse($products as $product) + @empty@endforelse +
ProductVersion / PlatformDownloadsStatusActions
{{ $product->name }}{{ \Illuminate\Support\Str::limit($product->description ?: $product->original_filename, 70) }}{{ $product->version ?: '—' }}{{ $product->platform ?: 'Unspecified' }} · {{ $product->formatted_size }}{{ number_format($product->download_count) }}{{ $product->is_published ? 'Published' : 'Draft' }}
@csrf @method('PATCH')
No additional products yet. Add your first installer below.
+
any() && !old('_method')) open @endif>Add a new productUpload an installer and choose whether to publish it.+
@csrf
+ @foreach($products as $product)
Edit {{ $product->name }}{{ $product->original_filename }} · {{ $product->formatted_size }}+
@csrf @method('PUT')
@csrf @method('DELETE')
@endforeach +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/index.blade.php new file mode 100755 index 0000000..61766a6 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/index.blade.php @@ -0,0 +1,6 @@ +@extends('layouts.app') +@section('content') +
Admin

Licensing administration

Manage licences, activations and customer support.

+
{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

+
Manage releasesManage licencesManage usersManage contentSEO & searchSupport inbox
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/license.blade.php b/integration/sonardesk-legacy/resources/views/admin/license.blade.php new file mode 100755 index 0000000..9a2ac70 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/license.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Licence •••• {{ $license->license_key_last4 }}

{{ $license->customer_name }} · {{ $license->customer_email }}

{{ $license->edition }} · {{ $license->activation_limit }} seats

@if($license->revoked_at)
@csrf
@else
@csrf
@endif

Activations

@foreach($license->activations as $a)@endforeach
IDPlatformVersionStatus
{{ $a->installation_name ?: $a->installation_id }}{{ $a->platform }}{{ $a->app_version }}{{ $a->deactivated_at ? 'Released':'Active' }}
@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/licenses.blade.php b/integration/sonardesk-legacy/resources/views/admin/licenses.blade.php new file mode 100755 index 0000000..8ff6b69 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/licenses.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Licences

@foreach($licenses as $l)@endforeach
CustomerEditionKeyStatus
{{ $l->customer_email ?: 'Manual' }}{{ $l->edition }}•••• {{ $l->license_key_last4 }}{{ $l->revoked_at ? 'Revoked':'Active' }}
{{ $licenses->links() }}
@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/releases/form.blade.php b/integration/sonardesk-legacy/resources/views/admin/releases/form.blade.php new file mode 100644 index 0000000..458dc97 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/releases/form.blade.php @@ -0,0 +1,9 @@ +@extends('layouts.app', ['title' => ($release->exists ? 'Edit release' : 'New release').' — SonarDesk Admin']) +@section('content') +
Distribution

{{ $release->exists ? 'Edit SonarDesk '.$release->version : 'Create a release' }}

Upload the notarized distribution package and control exactly when it becomes public.

Back to releases
+
@csrf @if($release->exists) @method('PUT') @endif +
01

Release identity

Version and compatibility information shown to customers.

+
02

Distribution package

DMG or ZIP. SHA-256 is calculated automatically after upload.

@if($release->file_path)
{{ $release->original_filename }}{{ $release->formatted_size }} · SHA-256 {{ $release->sha256 }}
@endif
+
03

Release notes

Professional customer-facing notes. Plain text is rendered safely with line breaks.

+
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/releases/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/releases/index.blade.php new file mode 100644 index 0000000..a8caa71 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/releases/index.blade.php @@ -0,0 +1,7 @@ +@extends('layouts.app', ['title' => 'Releases — SonarDesk Admin']) +@section('content') +
Distribution

Release management

Publish SonarDesk builds, manage the public download experience and keep Sparkle updates under control.

+
{{ $releases->total() }}

Total releases

{{ $publishedCount }}

Published

{{ number_format($downloadCount) }}

Total downloads

Live

Sparkle feed

+

SonarDesk builds

Only published builds with an uploaded package are available publicly.

View public page
@forelse($releases as $release)@empty@endforelse
ReleaseChannelStatusPackageDownloadsPublished
SonarDesk {{ $release->version }}Build {{ $release->build_number }} · macOS {{ $release->minimum_macos ?: 'Any supported' }}{{ ucfirst($release->channel) }}@if($release->is_published)Published@elseDraft@endif{{ $release->file_path ? $release->formatted_size : 'No package' }}{{ number_format($release->download_count) }}{{ $release->published_at?->format('j M Y H:i') ?: '—' }}Edit
@csrf @method('DELETE')
No releases yet. Create your first signed SonarDesk build.
+{{ $releases->links() }} +@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/releases/settings.blade.php b/integration/sonardesk-legacy/resources/views/admin/releases/settings.blade.php new file mode 100644 index 0000000..bddc7ac --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/releases/settings.blade.php @@ -0,0 +1,5 @@ +@extends('layouts.app', ['title' => 'Download page — SonarDesk Admin']) +@section('content') +
Website

Download page content

Control the customer-facing download area without editing a template or deploying code.

Back to releases
+
@csrf @method('PUT')
Preview page
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/seo/edit.blade.php b/integration/sonardesk-legacy/resources/views/admin/seo/edit.blade.php new file mode 100644 index 0000000..67fcb35 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/seo/edit.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app') +@section('content') +
+
Search & discovery

SEO settings

Control how SonarDesk appears in search engines and when pages are shared.

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

Site defaults

Fallback metadata used whenever a page does not provide its own SEO values.

Global
+
+ + +
+ + + + +
+ +
+
Social sharing

Open Graph & social

Professional defaults for link previews across social networks and messaging applications.

+ +
+ +
+ +
+
Google Search Console

Domain verification

Use a Domain property in Google Search Console. Google will provide a TXT value that you add at your DNS provider.

DNS TXT
+
Recommended verification methodAdd Google’s google-site-verification=… TXT record to the root domain. DNS verification covers all protocols and subdomains.
+ +
+ +
+
Crawler control

Indexing

Production crawler controls. Non-production environments remain blocked automatically.

+ +
+
Search configurationChanges apply to public metadata immediately.
+
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/support/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/support/index.blade.php new file mode 100755 index 0000000..ae56c8a --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/support/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app', ['title' => 'Support Inbox — SonarDesk']) +@section('content') +
Admin

Support inbox

Customer, licence, billing and privacy requests.

+
+ + +
+ +{{ $tickets->links() }} +@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/support/show.blade.php b/integration/sonardesk-legacy/resources/views/admin/support/show.blade.php new file mode 100755 index 0000000..4248c64 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/support/show.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app', ['title' => $ticket->reference.' — Support']) +@section('content') +← Support inbox +
{{ $ticket->reference }}

{{ $ticket->subject }}

{{ $ticket->name }} · {{ $ticket->email }}

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

Request

{{ ucfirst($ticket->category) }}{{ $ticket->created_at->format('j M Y H:i') }}
{!! nl2br(e($ticket->message)) !!}
Reply by email
+ +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/users/index.blade.php b/integration/sonardesk-legacy/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..9b6d9ef --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/users/index.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.app') +@section('content') +
+
Admin

Users

Manage portal accounts, access and customer licences.

+ Admin dashboard +
+ +
+ + + + @if(request()->hasAny(['q','status']))Clear@endif +
+ +
+

Portal users

{{ $users->total() }} account{{ $users->total() === 1 ? '' : 's' }}

+
+ @forelse($users as $user) + + + + + + + + + @empty@endforelse +
UserAccessEmailLicencesJoined
{{ $user->name }}#{{ $user->id }}@if($user->disabled_at)Suspended@elseif($user->is_admin)Admin@elseActive@endif{{ $user->email }}{{ $user->hasVerifiedEmail() ? 'Verified' : 'Not verified' }}{{ $licenseCounts[strtolower($user->email)] ?? 0 }}{{ $user->created_at?->format('d M Y') }}
No users found.
+
+
{{ $users->links() }}
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/admin/users/show.blade.php b/integration/sonardesk-legacy/resources/views/admin/users/show.blade.php new file mode 100644 index 0000000..825a9e1 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/admin/users/show.blade.php @@ -0,0 +1,57 @@ +@extends('layouts.app') +@section('content') +
+
Admin · User

{{ $user->name }}

{{ $user->email }}

+ Back to users +
+ +@if($errors->any())
Could not save changes
    @foreach($errors->all() as $error)
  • {{ $error }}
  • @endforeach
@endif + +
+
+
+
01

Account details

Edit the portal identity and administrator access.

+
@csrf @method('PUT') +
+ + @if(auth()->user()->is($user))

Your own administrator access is protected.

@endif + +
+
+ +
+
02

Licences

Licences linked to this user's email address.

+ @forelse($licenses as $license) +
+
+
{{ $license->edition }} · •••• {{ $license->license_key_last4 }}{{ $license->subscription_status ?: 'No subscription status' }} · {{ $license->payment_provider ?: 'manual' }}
+ +
+
@csrf @method('PATCH')
+ @if($license->activations->isNotEmpty()) +
+ @foreach($license->activations as $activation)@endforeach +
DevicePlatformVersionStatus
{{ $activation->installation_name ?: $activation->installation_id }}{{ $activation->platform ?: '—' }}{{ $activation->app_version ?: '—' }}{{ $activation->deactivated_at ? 'Released' : 'Active' }}@if(!$activation->deactivated_at)
@csrf @method('DELETE')
@endif
+ @endif +
+ @empty

No licences are linked to this email address.

@endforelse +
+
+ + +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/auth/forgot-password.blade.php b/integration/sonardesk-legacy/resources/views/auth/forgot-password.blade.php new file mode 100755 index 0000000..21c04c9 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/auth/forgot-password.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Reset password

@csrf
@endsection diff --git a/integration/sonardesk-legacy/resources/views/auth/login.blade.php b/integration/sonardesk-legacy/resources/views/auth/login.blade.php new file mode 100755 index 0000000..0701f4d --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/auth/login.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Sign in

@csrf
Forgot password?
@endsection diff --git a/integration/sonardesk-legacy/resources/views/auth/register.blade.php b/integration/sonardesk-legacy/resources/views/auth/register.blade.php new file mode 100755 index 0000000..64b5aa0 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/auth/register.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Create account

Use the same email address used to purchase SonarDesk.

@csrf
@endsection diff --git a/integration/sonardesk-legacy/resources/views/auth/reset-password.blade.php b/integration/sonardesk-legacy/resources/views/auth/reset-password.blade.php new file mode 100755 index 0000000..d11d1a7 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/auth/reset-password.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Choose a new password

@csrf
@endsection diff --git a/integration/sonardesk-legacy/resources/views/auth/verify-email.blade.php b/integration/sonardesk-legacy/resources/views/auth/verify-email.blade.php new file mode 100755 index 0000000..bde6474 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/auth/verify-email.blade.php @@ -0,0 +1 @@ +@extends('layouts.app') @section('content')

Verify your email

For security, licence ownership is only shown after the account email has been verified.

@csrf
@endsection diff --git a/integration/sonardesk-legacy/resources/views/checkout/buy.blade.php b/integration/sonardesk-legacy/resources/views/checkout/buy.blade.php new file mode 100755 index 0000000..525fc4c --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/checkout/buy.blade.php @@ -0,0 +1,301 @@ +@extends('layouts.app') + +@section('content') +
+ SonarDesk +

Choose your licence

+

+ Every subscription starts with a 7-day free trial. + Cancel before the trial ends and you won't be charged. +

+ + @if($paddleEnvironment === 'sandbox') +
+ Sandbox checkout — no real payment is taken +
+ @endif +
+ +
+ @foreach($plans as $code => $plan) +
+
+
+
+

{{ $plan['name'] }}

+
{{ $plan['billing'] }} billing
+
+ + @if($plan['billing'] === 'Annual') + Best value + @endif +
+ +
+ {{ $plan['display_price'] }} +
+ +
    +
  • 7-day free trial
  • +
  • + {{ $plan['activation_limit'] }} + active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }} +
  • +
  • Secure online activation
  • +
  • Licence management portal
  • +
+
+ + +
+ @endforeach +
+ + + + + + + + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/cms/show.blade.php b/integration/sonardesk-legacy/resources/views/cms/show.blade.php new file mode 100644 index 0000000..35da777 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/cms/show.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app', [ + 'title' => $page->meta_title ?: $page->title.' — '.\App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'), + 'description' => $page->meta_description ?: $page->excerpt, + 'keywords' => $page->meta_keywords, + 'canonical' => $page->canonical_url ?: null, + 'ogTitle' => $page->og_title ?: null, + 'ogDescription' => $page->og_description ?: null, + 'ogImage' => $page->og_image ?: null, + 'robotsIndex' => $page->robots_index, + 'robotsFollow' => $page->robots_follow, +]) +@section('content') + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/components/layouts/app.blade.php b/integration/sonardesk-legacy/resources/views/components/layouts/app.blade.php new file mode 100644 index 0000000..8f13014 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/components/layouts/app.blade.php @@ -0,0 +1,9 @@ +@props(['title' => null]) + +@extends('layouts.app') + +@section('title', $title ?? config('app.name')) + +@section('content') + {{ $slot }} +@endsection diff --git a/integration/sonardesk-legacy/resources/views/downloads/appcast.blade.php b/integration/sonardesk-legacy/resources/views/downloads/appcast.blade.php new file mode 100644 index 0000000..a7060d9 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/downloads/appcast.blade.php @@ -0,0 +1,2 @@ +@php echo ''; @endphp +SonarDesk Updates{{ route('updates.appcast') }}SonarDesk stable release feed@foreach($releases as $release)SonarDesk {{ $release->version }}{{ $release->published_at?->toRfc2822String() }}{{ $release->build_number }}{{ $release->version }}@if($release->minimum_macos){{ $release->minimum_macos }}@endifrelease_notes ?: 'SonarDesk update')) !!}]]>sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif />@endforeach diff --git a/integration/sonardesk-legacy/resources/views/downloads/index.blade.php b/integration/sonardesk-legacy/resources/views/downloads/index.blade.php new file mode 100644 index 0000000..ca4aa38 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/downloads/index.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.app', ['title' => 'Downloads — SonarDesk and developer tools', 'description' => 'Browse SonarDesk and other macOS developer utilities. View release details or download installers.']) +@section('fullbleed') +
+
Software downloads

Downloads

Find the latest releases of SonarDesk and our developer tools. View details, check requirements or download directly.

+
Available software

Product catalogue

All downloads in one place.

+
+@if($latest) +
macOSv{{ $latest->version }}

SonarDesk

{{ \Illuminate\Support\Str::limit(trim(preg_replace('/\s+/', ' ', strip_tags(\Illuminate\Support\Str::markdown($latest->release_notes ?: $settings['intro'], ['html_input'=>'strip','allow_unsafe_links'=>false])))), 175) }}

+@endif +@foreach($products as $product) +
{{ $product->platform ?: 'Software' }}{{ $product->version ? 'v'.$product->version : 'Latest' }}

{{ $product->name }}

{{ \Illuminate\Support\Str::limit(trim(preg_replace('/\s+/', ' ', strip_tags(\Illuminate\Support\Str::markdown($product->description ?: 'Download the latest available package.', ['html_input'=>'strip','allow_unsafe_links'=>false])))), 175) }}

+@endforeach +
+@if(!$latest && $products->isEmpty())

No published downloads are available yet.

@endif +
+@if($releases->isNotEmpty())
SonarDesk archive

Previous SonarDesk versions

Older published installers, grouped separately from current products.

@foreach($releases as $release)
SonarDesk {{ $release->version }}Build {{ $release->build_number }} · {{ ucfirst($release->channel) }} · {{ $release->published_at?->format('j M Y') }}
{{ $release->formatted_size }}Download
@endforeach
{{ $releases->links() }}
@endif +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/downloads/product.blade.php b/integration/sonardesk-legacy/resources/views/downloads/product.blade.php new file mode 100644 index 0000000..11903d4 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/downloads/product.blade.php @@ -0,0 +1,13 @@ +@extends('layouts.app', ['title' => $product->name.' — Downloads', 'description' => 'Download '.$product->name.' and read product information and release notes.']) +@section('fullbleed') +
+
+← All downloads +
{{ $product->platform ?: 'Software' }}{{ $product->version ? 'v'.$product->version : 'Latest' }}
+

{{ $product->name }}

+

{{ $product->formatted_size }} · {{ $product->platform ?: 'Software' }}

+Download {{ $product->name }} ↓ +
{!! \Illuminate\Support\Str::markdown($product->description ?: 'Download the latest available package.', ['html_input' => 'strip', 'allow_unsafe_links' => false]) !!}
+Download ↓ +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/downloads/sonardesk.blade.php b/integration/sonardesk-legacy/resources/views/downloads/sonardesk.blade.php new file mode 100644 index 0000000..a454983 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/downloads/sonardesk.blade.php @@ -0,0 +1,4 @@ +@extends('layouts.app', ['title' => 'SonarDesk — Details and download', 'description' => 'SonarDesk release notes, system requirements and macOS installer.']) +@section('fullbleed') +
← All downloads
macOSv{{ $latest->version }}

SonarDesk

{{ $settings['intro'] }}

Version {{ $latest->version }} · Build {{ $latest->build_number }} · {{ $latest->formatted_size }} · Released {{ $latest->published_at?->format('j F Y') }}

Download SonarDesk ↓

Release notes

{!! \Illuminate\Support\Str::markdown($latest->release_notes ?: 'Latest maintenance and improvements.', ['html_input'=>'strip','allow_unsafe_links'=>false]) !!}

System requirements

{{ $settings['requirements'] }}

@if($latest->minimum_macos)

Minimum macOS version: {{ $latest->minimum_macos }}

@endif

Installation

  1. Download the signed installer.
  2. Open the DMG and move SonarDesk to Applications.
  3. Launch SonarDesk and activate with your licence.
@if($latest->sha256)
SHA-256 checksum{{ $latest->sha256 }}
@endif
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/emails/license-key-text.blade.php b/integration/sonardesk-legacy/resources/views/emails/license-key-text.blade.php new file mode 100644 index 0000000..d6b0a09 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/emails/license-key-text.blade.php @@ -0,0 +1,23 @@ +SonarDesk — Your licence is ready + +{{ $customerName ? 'Hello '.$customerName.',' : 'Hello,' }} + +Thank you for choosing SonarDesk. Your {{ $edition }} licence key is: + +{{ $licenseKey }} + +To activate: +1. Open SonarDesk. +2. Open Settings → Licence. +3. Paste the key above and select Activate. + +@if($expiresAt)Current entitlement period: {{ $expiresAt->format('j M Y') }}. + +@endif +@if($portalUrl)Manage your licence: {{ $portalUrl }} + +@endif +Keep this key private. + +DOWNLOAD SONARDESK +{{ $downloadUrl }} diff --git a/integration/sonardesk-legacy/resources/views/emails/license-key.blade.php b/integration/sonardesk-legacy/resources/views/emails/license-key.blade.php new file mode 100644 index 0000000..0a052a4 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/emails/license-key.blade.php @@ -0,0 +1,20 @@ + + +Your SonarDesk licence key + +
+ + +
SonarDesk
Your licence is ready
+

{{ $customerName ? 'Hello '.$customerName.',' : 'Hello,' }}

+

Thank you for choosing SonarDesk. Your {{ $edition }} licence key is:

+
{{ $licenseKey }}
+

Activate SonarDesk

+
  1. Open SonarDesk on your Mac.
  2. Open Settings → Licence.
  3. Paste the licence key above and select Activate.
+@if($expiresAt)

Current entitlement period: {{ $expiresAt->format('j M Y') }}.

@endif +@if($portalUrl)

Manage your licence

@endif +

Download SonarDesk

+

Keep this key private. If you did not make this purchase, contact SonarDesk support.

+
+
+ diff --git a/integration/sonardesk-legacy/resources/views/features.blade.php b/integration/sonardesk-legacy/resources/views/features.blade.php new file mode 100644 index 0000000..5602e8a --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/features.blade.php @@ -0,0 +1,15 @@ +@extends('layouts.app', ['title' => 'Features — SonarDesk', 'description' => 'Explore SonarDesk features for monitoring SonarQube projects, quality gates, issues and measures from macOS.']) +@section('content') +
Features

A focused desktop companion for SonarQube.

Built around the checks developers make every day: project status, quality gates, issues, security findings and measures.

+
+
Projects

Project dashboard

Keep configured SonarQube projects together and scan project health without opening a collection of browser tabs.

+
Quality

Quality gates and measures

Surface the status and metrics that tell you whether a project needs attention, in a layout designed for quick review.

+
Issues

Bugs, vulnerabilities and code smells

Browse the major SonarQube issue categories, filter the list and open an issue to inspect its details and context.

+
Scale

Server-side pagination

Issue browsing is designed to remain practical on projects with larger result sets by paging data from the server.

+
Connections

Multiple SonarQube servers

Configure the environments you work with and keep them available from the same SonarDesk workspace.

+
Security

macOS Keychain

SonarQube tokens are stored using macOS Keychain rather than being treated as ordinary application preferences.

+
Licensing

Email delivery and self-service portal

After checkout, the licence key is emailed to the customer. The SonarDesk portal can resend the key and manage licence information and Mac activations.

+
Distribution

Built for direct Mac distribution

SonarDesk is distributed directly for macOS, giving the product a focused release and support channel outside the Mac App Store.

+
+
7-day free trial

Try the complete app.

Choose Personal for one active Mac or Professional for up to three.

View pricing
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/layouts/app.blade.php b/integration/sonardesk-legacy/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..8defb68 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/layouts/app.blade.php @@ -0,0 +1,529 @@ + + + + + + + + @php + $seoSiteName = \App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'); + $seoDefaultTitle = \App\Models\SiteSetting::valueFor('seo_default_title', 'SonarDesk — Native SonarQube Monitoring for macOS'); + $seoDefaultDescription = \App\Models\SiteSetting::valueFor('seo_default_description', 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.'); + $pageTitle = $title ?? $seoDefaultTitle; + $pageDescription = $description ?? $seoDefaultDescription; + $pageKeywords = $keywords ?? \App\Models\SiteSetting::valueFor('seo_default_keywords'); + $canonicalBase = rtrim(\App\Models\SiteSetting::valueFor('seo_canonical_base_url', config('app.url')), '/'); + $canonicalUrl = $canonical ?? ($canonicalBase . request()->getPathInfo()); + $socialImage = $ogImage ?? \App\Models\SiteSetting::valueFor('seo_default_og_image', url('/icons/og-sonardesk.png')); + $ogTitle = $ogTitle ?? $pageTitle; + $ogDescription = $ogDescription ?? $pageDescription; + $robotsIndex = $robotsIndex ?? true; + $robotsFollow = $robotsFollow ?? true; + $productionIndexing = app()->environment('production') && \App\Models\SiteSetting::booleanFor('seo_robots_enabled', true); + $robots = $productionIndexing ? (($robotsIndex ? 'index' : 'noindex').','.($robotsFollow ? 'follow' : 'nofollow').',max-image-preview:large,max-snippet:-1,max-video-preview:-1') : 'noindex,nofollow'; + $twitterSite = \App\Models\SiteSetting::valueFor('seo_twitter_site'); + $googleVerification = \App\Models\SiteSetting::valueFor('seo_google_site_verification'); + $organizationName = \App\Models\SiteSetting::valueFor('seo_organization_name', $seoSiteName); + $organizationLogo = \App\Models\SiteSetting::valueFor('seo_organization_logo', url('/icons/sonardesk-icon-512.png')); + $schema = [ + '@context' => 'https://schema.org', '@type' => 'SoftwareApplication', 'name' => $seoSiteName, + 'applicationCategory' => 'DeveloperApplication', 'operatingSystem' => 'macOS', 'url' => route('home'), + 'description' => $seoDefaultDescription, 'publisher' => ['@type' => 'Organization', 'name' => $organizationName, 'logo' => ['@type' => 'ImageObject', 'url' => $organizationLogo]], + ]; + @endphp + + {{ $pageTitle }} + + @if($pageKeywords)@endif + + + @if($googleVerification)@endif + + + + + + + + + + + + + + + + + + + + + + + + + @if($twitterSite)@endif + + + + + + + @stack('head') + + + + +@hasSection('fullbleed') + @yield('fullbleed') +@else +
+ @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
{{ $errors->first() }}
@endif + @yield('content') +
+@endif + + + + + + + + + + + + diff --git a/integration/sonardesk-legacy/resources/views/layouts/app.blade.php.tmp b/integration/sonardesk-legacy/resources/views/layouts/app.blade.php.tmp new file mode 100644 index 0000000..e69de29 diff --git a/integration/sonardesk-legacy/resources/views/legal/cookies.blade.php b/integration/sonardesk-legacy/resources/views/legal/cookies.blade.php new file mode 100755 index 0000000..06981ab --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/legal/cookies.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.app', ['title' => 'Cookie Policy — SonarDesk']) + +@section('content') + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/legal/privacy.blade.php b/integration/sonardesk-legacy/resources/views/legal/privacy.blade.php new file mode 100755 index 0000000..a19a562 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/legal/privacy.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app', ['title' => 'Privacy Policy — SonarDesk']) + +@section('content') +@php + $legal = config('sonardesk.legal'); + $retention = config('sonardesk.privacy.support_retention_days', 730); +@endphp + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/legal/refund.blade.php b/integration/sonardesk-legacy/resources/views/legal/refund.blade.php new file mode 100644 index 0000000..4410517 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/legal/refund.blade.php @@ -0,0 +1,102 @@ +@extends('layouts.app', ['title' => 'Refund Policy — SonarDesk']) + +@section('content') +@php($legal = config('sonardesk.legal')) + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/legal/terms.blade.php b/integration/sonardesk-legacy/resources/views/legal/terms.blade.php new file mode 100755 index 0000000..45c57de --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/legal/terms.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.app', ['title' => 'Terms of Service — SonarDesk']) + +@section('content') +@php($legal = config('sonardesk.legal')) + +@endsection diff --git a/integration/sonardesk-legacy/resources/views/portal/index.blade.php b/integration/sonardesk-legacy/resources/views/portal/index.blade.php new file mode 100755 index 0000000..5424076 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/portal/index.blade.php @@ -0,0 +1,114 @@ +@extends('layouts.app') + +@section('content') +
Latest SonarDeskNeed the Mac app?Download the latest published, signed release from the SonarDesk distribution centre.
Download SonarDesk
+
+
+ Account +

My licences

+

Manage subscriptions, licence keys and activated Macs.

+
+ Buy another licence +
+ +@php + $activeLicences = $licenses->filter(fn ($license) => $license->is_usable); + $historyLicences = $licenses->reject(fn ($license) => $license->is_usable); +@endphp + +@if($activeLicences->isEmpty()) +
+

No active licences

+

No active SonarDesk licence is associated with {{ auth()->user()->email }}.

+ Choose a plan +
+@else +
+ @foreach($activeLicences as $license) + @php + $activeCount = $license->activations->whereNull('deactivated_at')->count(); + $isDuplicatePersonal = $hasProfessional && $license->edition === 'Personal'; + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; + @endphp +
+
+
+
+
+ Active + {{ $license->edition }} + {{ $billing }} +
+

SonarDesk {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+ Manage licence +
+ +
+
+ Macs activated + {{ $activeCount }} of {{ $license->activation_limit }} +
+
+ Subscription + {{ ucfirst($license->subscription_status ?: 'active') }} +
+
+ {{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Renews' }} + {{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }} +
+
+ +
+ +
+ + @if($isDuplicatePersonal) +
+
+ You already have an active Professional subscription. +

This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.

+
+
+ @csrf + @method('DELETE') + +
+
+ @elseif($license->edition === 'Personal') +
+
+ Need more Macs? + Upgrade this subscription to Professional for up to 3 Macs. +
+
+ @csrf + +
+
+ @endif +
+
+ @endforeach +
+@endif + +@if($historyLicences->isNotEmpty()) +
+ Licence history {{ $historyLicences->count() }} +
+ @foreach($historyLicences as $license) +
+
+ SonarDesk {{ $license->edition }} + Licence ending {{ $license->license_key_last4 }} +
+
{{ ucfirst($license->subscription_status ?: ($license->revoked_at ? 'revoked' : 'unavailable')) }}
+ Details +
+ @endforeach +
+
+@endif +@endsection diff --git a/integration/sonardesk-legacy/resources/views/portal/show.blade.php b/integration/sonardesk-legacy/resources/views/portal/show.blade.php new file mode 100755 index 0000000..cc75ebb --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/portal/show.blade.php @@ -0,0 +1,81 @@ +@extends('layouts.app') + +@section('content') +
Latest SonarDeskNeed the Mac app?Download the latest published, signed release from the SonarDesk distribution centre.
Download SonarDesk
+@php + $activeActivations = $license->activations->whereNull('deactivated_at'); + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; +@endphp + +← My licences + +
+
+
+ {{ $license->is_usable ? 'Active' : 'Unavailable' }} + {{ $license->edition }} + {{ $billing }} +
+

SonarDesk {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

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

Activated Macs

+

{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.

+
+ {{ $activeActivations->count() }}/{{ $license->activation_limit }} +
+ + @forelse($activeActivations as $activation) +
+
⌘
+
+ {{ $activation->installation_name ?: 'Mac' }} + {{ $activation->platform ?: 'macOS' }}@if($activation->app_version) · SonarDesk {{ $activation->app_version }}@endif + Last seen {{ optional($activation->last_seen_at)->diffForHumans() ?: 'not yet recorded' }} +
+
+ @csrf + @method('DELETE') + +
+
+ @empty +
No Macs are currently activated with this licence.
+ @endforelse +
+ + +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/portal/welcome.blade.php b/integration/sonardesk-legacy/resources/views/portal/welcome.blade.php new file mode 100755 index 0000000..c56a643 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/portal/welcome.blade.php @@ -0,0 +1,50 @@ +@extends('layouts.app', [ + 'title' => 'SonarDesk — Native SonarQube monitoring for macOS', + 'description' => 'SonarDesk is a native macOS companion for SonarQube: monitor quality gates, issues, measures and project health from one focused desktop workspace.' +]) +@section('content') +
+ SonarQube, without the browser tab sprawl +

Your code quality.
At home on the Mac.

+

SonarDesk is a focused native macOS companion for developers and teams using SonarQube. See project health, quality gates, bugs, vulnerabilities, code smells and measures in one fast desktop workspace.

+ +

Direct download for macOS · Secure licence activation · Cancel anytime

+
+ +
+ Native macOS appMultiple SonarQube serversKeychain token storageIssue detail workflow +
+ +
+
Built for daily engineering work

See the signal. Open the issue. Get back to code.

SonarDesk keeps the information you check most often close at hand, without replacing SonarQube or changing your existing workflow.

+
+
01

Project health at a glance

Review configured projects, quality gate state and important measures from a clean Mac dashboard.

+
02

Issue details that are useful

Open bugs, vulnerabilities and code smells to see the context you need before switching back to the codebase.

+
03

Focused filtering

Work through issue lists with practical filters and server-side pagination rather than fighting a giant browser view.

+
04

Multiple servers

Keep the SonarQube environments you work with available from one desktop application.

+
05

macOS security

SonarQube access tokens are stored in the macOS Keychain. SonarDesk respects the permissions of the token you provide.

+
06

Native workflow

A purpose-built macOS interface with familiar navigation, desktop behaviour and a focused experience for engineering teams.

+
+
+ +
+
Designed to complement SonarQube

Keep SonarQube as the source of truth.

SonarDesk is a desktop companion, not a replacement. It connects to your SonarQube server using your credentials and presents the project and issue information you need for everyday monitoring.

+
Quality gates and measures
Bugs, vulnerabilities and code smells
Project and issue navigation
Secure token storage in Keychain
Customer portal for licences and activations
+
+ +
+
Straightforward pricing

Start small. Upgrade when you need more Macs.

Every plan includes the full SonarDesk application. Professional increases the activation allowance for people working across multiple Macs.

+
+
Personal

For one Mac

£24.99 / year

or £2.99 monthly

  • Full SonarDesk feature set
  • 1 active Mac
  • 7-day free trial
  • Licence key delivered by email
  • Customer licence portal
+ +
+ +
+ +
Desktop distribution

Ready when you are.

Download the latest signed SonarDesk release for macOS, review what changed, verify the package checksum and browse previous versions from one dedicated release centre.

@if($latestRelease)Latest stableVersion {{ $latestRelease->version }}Build {{ $latestRelease->build_number }} · {{ $latestRelease->formatted_size }} · macOS {{ $latestRelease->minimum_macos ?: 'supported versions' }}
SHA-256{{ Str::limit($latestRelease->sha256, 34) }}
@elseSonarDesk releasesDistribution centrePublished builds will appear here automatically.@endif
+ +
SonarDesk for macOS

Make SonarQube easier to keep an eye on.

Try SonarDesk for seven days and connect it to your existing SonarQube environment.

Start free trial
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/release.blade.php b/integration/sonardesk-legacy/resources/views/release.blade.php new file mode 100644 index 0000000..b43d39b --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/release.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.app', ['title' => 'SonarDesk 1.0 — Release', 'description' => 'SonarDesk 1.0 for macOS: a native desktop companion for SonarQube project health, quality gates and issues.']) +@section('content') +
Introducing SonarDesk 1.0

SonarQube monitoring, built for the Mac.

SonarDesk brings project health, quality gates, bugs, vulnerabilities, code smells and measures into a dedicated native macOS workspace.

+
+
+ Version 1.0

Why SonarDesk

+

SonarQube is an essential part of many development workflows, but checking it throughout the day often means returning to another browser tab, navigating back to a project and rebuilding context. SonarDesk is designed to make that recurring check faster and more focused on macOS.

+

It does not replace SonarQube. Your SonarQube server remains the source of truth. SonarDesk gives you a dedicated desktop view of the information you monitor most often.

+

What is included

+
  • Project dashboards and project health views
  • Quality gate status and important measures
  • Issue browsing for bugs, vulnerabilities and code smells
  • Detailed issue views and practical filtering
  • Server-side pagination for larger issue sets
  • Support for multiple SonarQube server configurations
  • SonarQube token storage using macOS Keychain
  • Direct licensing with a self-service customer portal
+

Who it is for

SonarDesk is for developers, technical leads and engineering teams who already use SonarQube and want a faster, dedicated way to keep project quality visible while working on a Mac.

+

Launch pricing

Personal is £24.99/year or £2.99/month for one active Mac. Professional is £44.99/year or £4.99/month for up to three active Macs. Both include a 7-day free trial and the complete SonarDesk feature set.

+
+ +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/seo/sitemap.blade.php b/integration/sonardesk-legacy/resources/views/seo/sitemap.blade.php new file mode 100644 index 0000000..517eb1c --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/seo/sitemap.blade.php @@ -0,0 +1,18 @@ +{!! '' !!} + +@foreach($static as $item) + + {{ route($item['route']) }} + {{ $item['frequency'] }} + {{ $item['priority'] }} + +@endforeach +@foreach($pages as $page) + + {{ route('cms.show', $page->slug) }} + {{ $page->updated_at->toAtomString() }} + {{ $page->sitemap_change_frequency }} + {{ number_format((float) $page->sitemap_priority, 1, '.', '') }} + +@endforeach + diff --git a/integration/sonardesk-legacy/resources/views/support.blade.php b/integration/sonardesk-legacy/resources/views/support.blade.php new file mode 100755 index 0000000..6bdee43 --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/support.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app', [ + 'title' => 'Support — SonarDesk', + 'description' => 'Contact SonarDesk support for technical, licence, billing and privacy enquiries.' +]) + +@section('content') +
+ Support +

How can we help?

+

Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.

+
+ +
+
+
+
+

Contact support

+

We will reply to the email address you provide.

+
+ Support ticket +
+ +
+ @csrf + + +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+ +
+
+ + + @error('category')
{{ $message }}
@enderror +
+
+ + + @error('subject')
{{ $message }}
@enderror +
+
+ +
+ + +
Do not include passwords, private SSH keys or your full licence token.
+ @error('message')
{{ $message }}
@enderror +
+ + + @error('privacy_notice')
{{ $message }}
@enderror + +
+ + @auth + @if(auth()->user()->hasVerifiedEmail())Open licence portal@endif + @endauth +
+
+
+ + +
+@endsection diff --git a/integration/sonardesk-legacy/resources/views/welcome.blade.php b/integration/sonardesk-legacy/resources/views/welcome.blade.php new file mode 100755 index 0000000..26e294a --- /dev/null +++ b/integration/sonardesk-legacy/resources/views/welcome.blade.php @@ -0,0 +1,223 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + @fonts + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
+ @if (Route::has('login')) + + @endif +
+
+
+
+

Let's get started

+

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

+ + + +

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

+
+
+ {{-- Laravel Logo --}} + + + + + + + + + + + {{-- 13 --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + @if (Route::has('login')) + + @endif + + diff --git a/integration/sonardesk-legacy/routes/api.php b/integration/sonardesk-legacy/routes/api.php new file mode 100755 index 0000000..deb587a --- /dev/null +++ b/integration/sonardesk-legacy/routes/api.php @@ -0,0 +1,3 @@ +dailyAt('03:20')->withoutOverlapping(); + +Schedule::call(function (): void { + app(\Modules\Licensing\Services\LicenseDeliveryService::class)->deliverPending(50); +})->name('license-delivery')->everyMinute()->withoutOverlapping(); diff --git a/integration/sonardesk-legacy/routes/web.php b/integration/sonardesk-legacy/routes/web.php new file mode 100644 index 0000000..9f8359f --- /dev/null +++ b/integration/sonardesk-legacy/routes/web.php @@ -0,0 +1,99 @@ +name('sitemap'); +Route::get('/{key}.txt', [SeoController::class, 'indexNowKey'])->where('key', '[A-Fa-f0-9]{8,128}')->name('indexnow.key'); +Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots'); + +Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home'); +Route::view('/features', 'features')->name('features'); +Route::get('/release', [ReleaseController::class, 'index'])->name('release'); +Route::get('/downloads/sonardesk/details', [ReleaseController::class, 'sonarDeskDetails'])->name('download.sonardesk.details'); +Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest'); +Route::get('/downloads/products/{product}/details', [ReleaseController::class, 'productDetails'])->name('download.product.details'); +Route::get('/downloads/products/{product}', [ReleaseController::class, 'productDownload'])->name('download.product'); +Route::get('/downloads/{release}', [ReleaseController::class, 'download'])->name('download.release'); +Route::get('/updates/appcast.xml', [ReleaseController::class, 'appcast'])->name('updates.appcast'); +Route::get('/support', [SupportController::class, 'create'])->name('support'); +Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store'); +Route::view('/privacy', 'legal.privacy')->name('privacy'); +Route::view('/terms', 'legal.terms')->name('terms'); +Route::view('/refund-policy', 'legal.refund')->name('refund'); +Route::view('/cookies', 'legal.cookies')->name('cookies'); +Route::middleware('guest')->group(function () { + Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('throttle:login'); + Route::get('/register', [RegisteredUserController::class, 'create'])->name('register'); + Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('throttle:registration'); + Route::get('/forgot-password', [PasswordController::class, 'requestForm'])->name('password.request'); + Route::post('/forgot-password', [PasswordController::class, 'email'])->name('password.email')->middleware('throttle:5,1'); + Route::get('/reset-password/{token}', [PasswordController::class, 'resetForm'])->name('password.reset'); + Route::post('/reset-password', [PasswordController::class, 'reset'])->name('password.update'); +}); +Route::middleware('auth')->group(function () { + Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); + Route::get('/verify-email', [EmailVerificationController::class, 'notice'])->name('verification.notice'); + Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); + Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); +}); +Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () { + Route::get('/', [AdminController::class, 'index'])->name('index'); + Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses'); + Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license'); + Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke'); + Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore'); + Route::get('/users', [UserController::class, 'index'])->name('users.index'); + Route::get('/users/{user}', [UserController::class, 'show'])->name('users.show'); + Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update'); + Route::post('/users/{user}/verify', [UserController::class, 'verify'])->name('users.verify'); + Route::post('/users/{user}/verification-email', [UserController::class, 'resendVerification'])->name('users.verification-email'); + Route::post('/users/{user}/password-reset', [UserController::class, 'sendPasswordReset'])->name('users.password-reset'); + Route::post('/users/{user}/suspend', [UserController::class, 'suspend'])->name('users.suspend'); + Route::post('/users/{user}/restore', [UserController::class, 'restore'])->name('users.restore'); + Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy'); + Route::patch('/users/{user}/licences/{license}', [UserController::class, 'updateLicense'])->name('users.licences.update'); + Route::delete('/users/{user}/licences/{license}/activations/{activation}', [UserController::class, 'deactivate'])->name('users.activations.destroy'); + Route::get('/seo', [SeoSettingsController::class, 'edit'])->name('seo.edit'); + Route::put('/seo', [SeoSettingsController::class, 'update'])->name('seo.update'); + Route::get('/cms', [AdminCmsPageController::class, 'index'])->name('cms.index'); + Route::get('/cms/create', [AdminCmsPageController::class, 'create'])->name('cms.create'); + Route::post('/cms', [AdminCmsPageController::class, 'store'])->name('cms.store'); + Route::get('/cms/{page}/edit', [AdminCmsPageController::class, 'edit'])->name('cms.edit'); + Route::put('/cms/{page}', [AdminCmsPageController::class, 'update'])->name('cms.update'); + Route::delete('/cms/{page}', [AdminCmsPageController::class, 'destroy'])->name('cms.destroy'); + Route::get('/download-products', [DownloadProductController::class, 'index'])->name('download-products.index'); + Route::post('/download-products', [DownloadProductController::class, 'store'])->name('download-products.store'); + Route::put('/download-products/{product}', [DownloadProductController::class, 'update'])->name('download-products.update'); + Route::patch('/download-products/{product}/publication', [DownloadProductController::class, 'publication'])->name('download-products.publication'); + Route::delete('/download-products/{product}', [DownloadProductController::class, 'destroy'])->name('download-products.destroy'); + Route::get('/releases', [AdminReleaseController::class, 'index'])->name('releases.index'); + Route::get('/releases/create', [AdminReleaseController::class, 'create'])->name('releases.create'); + Route::post('/releases', [AdminReleaseController::class, 'store'])->name('releases.store'); + Route::get('/releases/settings', [AdminReleaseController::class, 'settings'])->name('releases.settings'); + Route::put('/releases/settings', [AdminReleaseController::class, 'updateSettings'])->name('releases.settings.update'); + Route::get('/releases/{release}/edit', [AdminReleaseController::class, 'edit'])->name('releases.edit'); + Route::put('/releases/{release}', [AdminReleaseController::class, 'update'])->name('releases.update'); + Route::delete('/releases/{release}', [AdminReleaseController::class, 'destroy'])->name('releases.destroy'); + Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index'); + Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show'); + Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update'); +}); + +Route::get('/pages/{slug}', [CmsPageController::class, 'show'])->name('cms.show'); diff --git a/integration/sonardesk-legacy/tests/Feature/CheckoutPageTest.php b/integration/sonardesk-legacy/tests/Feature/CheckoutPageTest.php new file mode 100755 index 0000000..b8dff77 --- /dev/null +++ b/integration/sonardesk-legacy/tests/Feature/CheckoutPageTest.php @@ -0,0 +1,29 @@ +set('paddle.client_side_token', 'test_example'); + config()->set('paddle.environment', 'sandbox'); + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('paddle.personal_annual_price_id', 'pri_personal_annual'); + config()->set('paddle.professional_monthly_price_id', 'pri_professional_monthly'); + config()->set('paddle.professional_annual_price_id', 'pri_professional_annual'); + + $this->get('/buy') + ->assertOk() + ->assertSee('7-day free trial') + ->assertSee('pri_personal_monthly') + ->assertSee('pri_personal_annual') + ->assertSee('pri_professional_monthly') + ->assertSee('pri_professional_annual') + ->assertSee('test_example'); + } +} diff --git a/integration/sonardesk-legacy/tests/Feature/LicenseActivationTest.php b/integration/sonardesk-legacy/tests/Feature/LicenseActivationTest.php new file mode 100755 index 0000000..ebb0b4e --- /dev/null +++ b/integration/sonardesk-legacy/tests/Feature/LicenseActivationTest.php @@ -0,0 +1,85 @@ + 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $one = ['licenseKey' => $key, 'installationID' => '11111111-1111-4111-8111-111111111111', 'appVersion' => '1.0.0', 'platform' => 'macOS']; + $this->postJson('/api/v1/activate', $one)->assertOk()->assertJsonStructure(['licenseToken']); + $this->postJson('/api/v1/activate', $one)->assertOk(); + $this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409); + } + + public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-active', + 'name' => 'Validate Active', + 'edition' => 'Professional', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '11111111-1111-4111-8111-111111111111', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.1', + 'platform' => 'macOS', + ])->assertOk()->assertJson([ + 'active' => true, + ])->assertJsonStructure(['licenseToken']); + } + + public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-deactivated', + 'name' => 'Validate Deactivated', + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '22222222-2222-4222-8222-222222222222', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ])->assertStatus(403)->assertJson([ + 'message' => 'This Mac has been deactivated from the licence portal.', + ]); + } +} diff --git a/integration/sonardesk-legacy/tests/Feature/PaddleSubscriptionProvisioningTest.php b/integration/sonardesk-legacy/tests/Feature/PaddleSubscriptionProvisioningTest.php new file mode 100755 index 0000000..1c062e1 --- /dev/null +++ b/integration/sonardesk-legacy/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -0,0 +1,132 @@ + strtolower($edition).'-'.$price, + 'name' => $edition, + 'edition' => $edition, + 'activation_limit' => $activations, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => $edition === 'Personal' ? 'pro_personal' : 'pro_professional', + 'external_price_id' => $price, + 'active' => true, + ]); + } + + private function subscription(string $type, string $status, string $price, string $product, array $extra = []): array + { + return [ + 'event_type' => $type, + 'data' => array_replace_recursive([ + 'id' => 'sub_test', + 'status' => $status, + 'customer_id' => 'ctm_test', + 'transaction_id' => 'txn_test', + 'custom_data' => ['customer_name' => 'Mark', 'customer_email' => 'mark@example.com'], + 'current_billing_period' => ['starts_at' => '2026-09-09T00:00:00Z', 'ends_at' => '2026-10-09T00:00:00Z'], + 'scheduled_change' => null, + 'items' => [[ + 'recurring' => true, + 'price' => ['id' => $price, 'product_id' => $product], + ]], + ], $extra), + ]; + } + + public function test_trialing_subscription_issues_a_usable_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'trialing', 'pri_personal_monthly', 'pro_personal' + )); + + $license = License::firstOrFail(); + $this->assertSame('Personal', $license->edition); + $this->assertSame('sub_test', $license->external_subscription_id); + $this->assertSame('trialing', $license->subscription_status); + $this->assertTrue($license->is_usable); + } + + public function test_upgrade_changes_entitlement_without_changing_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + $this->product('pri_professional_monthly', 'Professional', 3); + $service = app(PurchaseProvisioner::class); + + $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); + $id = License::firstOrFail()->id; + $service->provision('paddle', $this->subscription('subscription.updated', 'active', 'pri_professional_monthly', 'pro_professional')); + + $license = License::firstOrFail(); + $this->assertSame($id, $license->id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + } + + public function test_scheduled_cancellation_does_not_revoke_before_effective_date(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', + ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']] + )); + + $license = License::firstOrFail(); + $this->assertNull($license->revoked_at); + $this->assertNotNull($license->cancel_scheduled_at); + } + + public function test_canceled_subscription_revokes_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + $service = app(PurchaseProvisioner::class); + $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); + $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['current_billing_period' => null])); + + $license = License::firstOrFail(); + $this->assertNotNull($license->revoked_at); + $this->assertFalse($license->is_usable); + } + + public function test_new_purchase_creates_a_portal_user_for_the_customer(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $user = User::where('email', 'mark@example.com')->firstOrFail(); + $this->assertSame('Mark', $user->name); + $this->assertNotNull($user->email_verified_at); + } + + public function test_existing_customer_account_is_reused(): void + { + User::factory()->create(['name' => 'Existing Mark', 'email' => 'mark@example.com']); + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'active', 'pri_personal_monthly', 'pro_personal' + )); + + $this->assertSame(1, User::where('email', 'mark@example.com')->count()); + $this->assertSame('Existing Mark', User::where('email', 'mark@example.com')->firstOrFail()->name); + } +} diff --git a/integration/sonardesk-legacy/tests/Feature/PortalSubscriptionManagementTest.php b/integration/sonardesk-legacy/tests/Feature/PortalSubscriptionManagementTest.php new file mode 100755 index 0000000..8e23027 --- /dev/null +++ b/integration/sonardesk-legacy/tests/Feature/PortalSubscriptionManagementTest.php @@ -0,0 +1,69 @@ + $code, + 'name' => 'SonarDesk '.$edition.' '.(str_contains($code, 'annual') ? 'Annual' : 'Monthly'), + 'edition' => $edition, + 'activation_limit' => $limit, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => 'pro_'.strtolower($edition), + 'external_price_id' => $price, + 'active' => true, + ]); + } + + public function test_personal_subscription_is_upgraded_in_place(): void + { + $this->withoutMiddleware(PreventRequestForgery::class); + + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('paddle.professional_monthly_price_id', 'pri_professional_monthly'); + + $personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1); + $professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3); + $user = User::factory()->create(['email' => 'mark@example.com', 'email_verified_at' => now()]); + $license = License::create([ + 'license_product_id' => $personal->id, + 'license_key_hash' => str_repeat('a', 64), + 'license_key_last4' => 'TEST', + 'customer_email' => $user->email, + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_subscription_id' => 'sub_test', + 'external_price_id' => 'pri_personal_monthly', + 'subscription_status' => 'active', + ]); + + $this->mock(PaddleClient::class, function (MockInterface $mock) { + $mock->shouldReceive('updateSubscriptionPrice')->once()->with('sub_test', 'pri_professional_monthly')->andReturn(['status' => 'active']); + }); + + $this->actingAs($user)->post(route('portal.upgrade', $license))->assertRedirect(route('portal.show', $license)); + + $license->refresh(); + $this->assertSame($professional->id, $license->license_product_id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + $this->assertSame('sub_test', $license->external_subscription_id); + } +} diff --git a/integration/sonardesk-legacy/tests/Feature/ReleaseDistributionTest.php b/integration/sonardesk-legacy/tests/Feature/ReleaseDistributionTest.php new file mode 100644 index 0000000..a12217a --- /dev/null +++ b/integration/sonardesk-legacy/tests/Feature/ReleaseDistributionTest.php @@ -0,0 +1,45 @@ +get('/release')->assertOk()->assertSee('Download SonarDesk'); + } + + public function test_latest_download_returns_published_package(): void + { + Storage::fake('local'); + Storage::disk('local')->put('sonardesk/releases/SonarDesk.dmg', 'package'); + SonarDeskRelease::create([ + 'version' => '1.2.0', 'build_number' => 120, 'channel' => 'stable', + 'file_path' => 'sonardesk/releases/SonarDesk.dmg', 'original_filename' => 'SonarDesk-1.2.0.dmg', + 'file_size' => 7, 'sha256' => hash('sha256', 'package'), 'is_published' => true, 'published_at' => now(), + ]); + $this->get('/download')->assertOk()->assertDownload('SonarDesk-1.2.0.dmg'); + } + + public function test_draft_release_is_not_downloadable(): void + { + $release = SonarDeskRelease::create(['version'=>'1.0.0','build_number'=>1,'channel'=>'stable','is_published'=>false]); + $this->get('/downloads/'.$release->id)->assertNotFound(); + } + + public function test_appcast_contains_published_release(): void + { + SonarDeskRelease::create([ + 'version'=>'2.0.0','build_number'=>200,'channel'=>'stable','file_path'=>'sonardesk/releases/a.dmg', + 'original_filename'=>'a.dmg','file_size'=>10,'sha256'=>str_repeat('a',64),'is_published'=>true,'published_at'=>now(), + ]); + $this->get('/updates/appcast.xml')->assertOk()->assertSee('SonarDesk 2.0.0')->assertSee('sparkle:version', false); + } +} diff --git a/integration/sonardesk-legacy/tests/TestCase.php b/integration/sonardesk-legacy/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/integration/sonardesk-legacy/tests/TestCase.php @@ -0,0 +1,10 @@ +

Focus on what matters.

SonarDesk brings SonarQube project health and issue exploration to a native macOS workspace.

Quality gates

Understand project health quickly.

Issue details

Explore bugs, vulnerabilities and code smells.

Multiple projects

Organise and switch between your projects.

@endsection \ No newline at end of file diff --git a/resources/views/sonardesk/home.blade.php b/resources/views/sonardesk/home.blade.php new file mode 100644 index 0000000..ff340d2 --- /dev/null +++ b/resources/views/sonardesk/home.blade.php @@ -0,0 +1,3 @@ +@extends('sonardesk.layout') +@section('title','SonarDesk for macOS') +@section('content')

NATIVE MACOS · SONARQUBE

Code quality, without the clutter.

Monitor projects, inspect issues and follow quality gates from a purpose-built macOS dashboard.

Explore featuresView pricing

At a glance

Keep quality gates, bugs, vulnerabilities and code smells in view.

Project focused

Switch between projects and investigate issues with fewer distractions.

Built for macOS

A native interface designed for your Mac.

@endsection \ No newline at end of file diff --git a/resources/views/sonardesk/layout.blade.php b/resources/views/sonardesk/layout.blade.php new file mode 100644 index 0000000..ef19561 --- /dev/null +++ b/resources/views/sonardesk/layout.blade.php @@ -0,0 +1 @@ +@yield('title', 'SonarDesk') · ABSDev
SonarDesk
@yield('content')
SonarDesk by ABSDev · macOS software
\ No newline at end of file diff --git a/resources/views/sonardesk/pricing.blade.php b/resources/views/sonardesk/pricing.blade.php new file mode 100644 index 0000000..b065311 --- /dev/null +++ b/resources/views/sonardesk/pricing.blade.php @@ -0,0 +1,3 @@ +@extends('sonardesk.layout') +@section('title','Pricing') +@section('content')

Simple plans for developers.

Choose a plan that fits your workflow. Checkout will be available once the existing payment integration has passed migration verification.

Personal

£4.99/month or £39.99/year

Professional

£8.99/month or £79.99/year

@endsection \ No newline at end of file diff --git a/routes/sonardesk.php b/routes/sonardesk.php new file mode 100644 index 0000000..2608451 --- /dev/null +++ b/routes/sonardesk.php @@ -0,0 +1,8 @@ + view('sonardesk.home'))->name('sonardesk.home'); +Route::get('/features', fn () => view('sonardesk.features'))->name('sonardesk.features'); +Route::get('/pricing', fn () => view('sonardesk.pricing'))->name('sonardesk.pricing');