diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..9c6888335 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +docker/development/mysql +docker/standalone/mysql +vendor +node_modules diff --git a/.env.example b/.env.example index bf995c5bc..505e4c54e 100644 --- a/.env.example +++ b/.env.example @@ -61,7 +61,7 @@ MAIL_FROM_NAME="${APP_NAME}" ### --- Logging Settings --- ### LOG_CHANNEL=stack -LOG_LEVEL=debug +LOG_LEVEL=warning ### --- Logging Settings End --- ### ### --- Cache and Queue Settings --- ### @@ -69,6 +69,7 @@ CACHE_DRIVER=file QUEUE_CONNECTION=database SESSION_DRIVER=file SESSION_LIFETIME=120 +SESSION_SECURE_COOKIE=true SETTINGS_CACHE_ENABLED=true ### --- Cache and Queue Settings End --- ### diff --git a/CODE_REVIEW_FINDINGS_2026-03-27.md b/CODE_REVIEW_FINDINGS_2026-03-27.md new file mode 100644 index 000000000..0271df939 --- /dev/null +++ b/CODE_REVIEW_FINDINGS_2026-03-27.md @@ -0,0 +1,130 @@ +# Code Review Findings + +Date: 2026-03-27 +Scope: static review of the current `cpgg` workspace +Notes: archived review snapshot. The findings below were used as the remediation checklist for the follow-up fix pass on the same date and should not be treated as the current open-issues list. + +## Critical + +1. `app/Extensions/PaymentGateways/PayPal/PayPalExtension.php:257-278` selects sandbox vs live API base URL and credentials from `config('app.env')`. A production install running in a non-`local` test environment will hit live PayPal with the wrong credentials. +2. `app/Extensions/PaymentGateways/Stripe/StripeExtension.php:282-289` selects Stripe live vs test secret from `config('app.env')` instead of an explicit gateway mode setting. +3. `app/Extensions/PaymentGateways/Stripe/StripeExtension.php:294-299` selects the webhook signing secret from raw `env('APP_ENV')` at runtime, creating the same mode drift and bypassing Laravel config caching semantics. +4. `app/Extensions/PaymentGateways/PayPal/routes.php:6-12` protects the browser return route with `auth`. If the session is lost during the provider round-trip, a completed payment cannot be confirmed by the returning user. +5. `app/Extensions/PaymentGateways/Stripe/routes.php:6-12` has the same `auth`-gated browser return problem for Stripe. +6. `app/Extensions/PaymentGateways/Mollie/routes.php:6-12` has the same `auth`-gated browser return problem for Mollie. +7. `app/Extensions/PaymentGateways/MercadoPago/routes.php:6-12` has the same `auth`-gated browser return problem for Mercado Pago. +8. `app/Extensions/PaymentGateways/Mollie/MollieExtension.php:99-100` marks the local payment as `processing` on the success redirect without verifying the remote payment status. +9. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:120-121` marks the local payment as `processing` on the success redirect without verifying the remote payment status. +10. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:60-64` sends both `success` and `pending` returns to the same success handler, so pending payments are treated like approved browser returns. +11. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:135-137` immediately returns success for webhook topics `merchant_order` and `payment`, which can skip legitimate webhook processing depending on Mercado Pago’s payload format. +12. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:147-150` contains a hard-coded webhook test bypass for notification ID `123456` in production code. +13. `app/Models/User.php:112-143` performs destructive account deletion work outside a transaction. A mid-delete failure can leave orphaned rows and an out-of-sync remote Pterodactyl user. +14. `app/Console/Commands/ChargeServers.php:124-137` returns `false` both for canceled servers and for insufficient credits, and the caller suspends on any `false`, so canceled servers can still be suspended. + +## High + +15. `app/Extensions/PaymentGateways/Mollie/MollieExtension.php:58` builds the payment description with `$shopProduct->name`, but `ShopProduct` exposes `display`, not `name` (`app/Models/ShopProduct.php:35-43`). +16. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:72` has the same nonexistent `$shopProduct->name` usage. +17. `app/Providers/SettingsServiceProvider.php:47` overwrites the Discord redirect URL from `app.url` on every request, ignoring any explicit redirect override in configuration. +18. `app/Providers/SettingsServiceProvider.php:64-65` swallows broad settings-load exceptions and keeps booting, which can leave the app running with partial configuration and only a log line. +19. `app/Providers/SettingsServiceProvider.php:71-73` falls back to theme `default` only in memory. The invalid theme value is never repaired, so the fallback work repeats every request. +20. `app/Providers/SettingsServiceProvider.php:84-85` swallows mail/theme bootstrap failures the same way, hiding broken runtime state behind a generic log entry. +21. `app/Providers/SettingsServiceProvider.php:35` calls `Schema::hasColumn('settings', 'payload')` during every boot cycle, which is an avoidable schema lookup on every request. +22. `app/Http/Controllers/Admin/SettingsController.php:81-82` dereferences `$optionInputData[$key]['type']` without a null check. Settings without metadata trigger an undefined index notice. +23. `app/Models/DiscordUser.php:92` logs `$this->role_id_on_purchase`, but that property is not defined on the model, so the activity log message is wrong or noisy. +24. `app/Http/Controllers/Admin/UserController.php:438` hard-codes role ID `1` to detect the last admin instead of checking by role name/permission. +25. `app/Listeners/UserPayment.php:98-99` hard-codes role IDs `4` and `3`, so role table reordering or reseeding breaks referral-role promotion. +26. `app/Console/Commands/ImportUsersFromPteroCommand.php:58-60` calls `array_key_exists(2, $json)` without verifying that `json_decode()` returned an array-like structure, so invalid JSON can trigger a fatal type error. +27. `app/Console/Commands/ImportUsersFromPteroCommand.php:64` assumes `$json[2]->data` exists. Any export format drift breaks the import path immediately. +28. `app/Console/Commands/ImportUsersFromPteroCommand.php:119-120` writes the discontinued `role` column instead of assigning Spatie roles, so imported admin/member state is not migrated correctly. +29. `app/Console/Commands/ImportUsersFromPteroCommand.php:119` copies the external password field blindly, with no hash compatibility check or rehash flow. +30. `app/Console/Commands/update.php:48-49` claims the minimum supported PHP version is `8.0.0`, which no longer matches the project’s current runtime requirements. +31. `app/Console/Commands/update.php:54-166` only performs work inside the interactive branch. In non-interactive runs it becomes a silent no-op. +32. `app/Console/Commands/update.php:101-105` ignores the exit status of `git pull`, so a failed update can continue as if the source tree changed successfully. +33. `app/Console/Commands/update.php:128-132` ignores the exit status of `composer install`, so the command can continue after a dependency failure. +34. `app/Console/Commands/update.php:150-156` recursively `chown`s the whole project root, including `.git` and non-runtime files, which is unsafe for many deployment layouts. +35. `app/Models/Server.php:98-99` assumes the Pterodactyl error payload always contains `errors[0]`. Unexpected API bodies will cause undefined index errors while deleting servers. +36. `app/Models/Server.php:126-134` returns the server model even when remote suspension fails, so callers cannot distinguish success from no-op failure. +37. `app/Models/Server.php:142-153` has the same silent-failure behavior for unsuspension. +38. `app/Models/User.php:281-283` unsuspends each server if the user has enough credits for that one server, but never reserves/decrements credits across the loop, so one balance can unlock multiple servers. +39. `app/Models/User.php:267-289` ignores whether `Server::suspend()` or `Server::unSuspend()` actually succeeded remotely before updating the user’s suspended state. +40. `app/Console/Commands/CleanupOpenPayments.php:33` deletes all locally open payments after one hour without checking provider state first, so slow-but-valid offsite payments can be discarded. +41. `app/Http/Controllers/Admin/CouponController.php:75-86` bulk coupon creation is not wrapped in a transaction. A duplicate code or DB error leaves a partial batch committed. +42. `app/Models/Coupon.php:139-149` generates coupon codes without any uniqueness check against the database or the current batch, so collisions are possible. +43. `app/Http/Controllers/TicketsController.php:246` renders an extra stray `` in the actions column HTML, which can break the DOM around the datatable row. + +## Medium + +44. `app/Http/Controllers/Api/NotificationController.php:40` accepts an unbounded `per_page` query value, allowing very large paginated reads from the notifications API. +45. `app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php:28` has no maximum length for notification titles. +46. `app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php:29` has no maximum length for notification content. +47. `app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php:26` has no maximum length for global notification titles. +48. `app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php:27` has no maximum length for global notification content. +49. `app/Http/Middleware/ApiAuthToken.php:33` updates `last_used` before the request is actually handled, so failed requests still mutate token usage and every API hit performs an immediate write. +50. `app/Http/Middleware/SecurityHeaders.php:31-33` only emits HSTS when `app.env === production`, so HTTPS staging or custom environments miss HSTS entirely. +51. `app/Http/Middleware/LastSeen.php:20-21` disables last-seen and IP tracking for all `local` environments, which makes local parity debugging and QA less accurate. +52. `app/Models/User.php:108-110` sends `WelcomeMessage` on every user creation, including imports, seeders, tests, and admin-created accounts. +53. `app/Models/User.php:115-118` deletes owned servers one-by-one without chunking, which is a poor fit for large accounts and can exhaust memory or lock time. +54. `app/Models/User.php:142` ignores the result of the remote Pterodactyl user deletion request entirely. +55. `app/Console/Commands/ChargeServers.php:51-145` does not return a proper command status code from `handle()`. +56. `app/Console/Commands/update.php:92-94` returns `false` instead of a Symfony command status constant. +57. `app/Console/Commands/ImportUsersFromPteroCommand.php:30` still uses the placeholder description `Command description`. +58. `app/Console/Commands/ImportUsersFromPteroCommand.php:100` returns `true` instead of a Symfony command status constant. +59. `database/factories/PaymentFactory.php:20` still defines `payer_id`, which is not part of the current `Payment` model fillable schema (`app/Models/Payment.php:21-35`). +60. `database/factories/PaymentFactory.php:27` still defines `payer`, which is also absent from the current `Payment` model schema. +61. `database/factories/PaymentFactory.php:23` uses the legacy status string `Completed` instead of the current payment status enum values (`app/Enums/PaymentStatus.php:6-11`). +62. `database/factories/PaymentFactory.php:18-28` no longer reflects the modern payment shape at all: it omits fields like `tax_value`, `tax_percent`, `total_price`, `shop_item_product_id`, and `payment_method`, making factory-backed tests misleading. +63. `app/Helpers/CallHomeHelper.php:32-35` performs an outbound install telemetry request with no timeout. +64. `app/Helpers/CallHomeHelper.php:32-35` uses `Http::async()->post(...)->wait()`, so the code still blocks the request path even though it is labeled async. +65. `app/Helpers/CallHomeHelper.php:31-33` hashes the installation host with MD5 into a stable external identifier. Even if pseudonymous, it is still persistent installation fingerprinting. +66. `app/Helpers/CallHomeHelper.php:37` writes the flag file without file locking, so concurrent first-hit requests can race. +67. `app/Classes/PterodactylClient.php:52-56` builds the user client without any request timeout, so panel calls can hang indefinitely on network issues. +68. `app/Classes/PterodactylClient.php:61-65` builds the admin client without any request timeout. +69. `app/Models/DiscordUser.php:64-77` calls Discord role add/remove endpoints without a timeout. +70. `app/Http/Controllers/Auth/SocialiteController.php:86-94` calls the Discord guild join endpoint without a timeout. +71. `app/Extensions/PaymentGateways/Mollie/MollieExtension.php:50-53` creates Mollie payments without a timeout. +72. `app/Extensions/PaymentGateways/Mollie/MollieExtension.php:111-114` looks up Mollie webhook payments without a timeout. +73. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:56-59` creates Mercado Pago checkout preferences without a timeout. +74. `app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php:159-162` looks up Mercado Pago payments without a timeout. +75. `app/Http/Controllers/Admin/CouponController.php:217` validates `range_codes` with `digits_between:1,100`, which constrains the number of digits, not the actual batch size. Values like `9999999999` still pass as “10 digits”. + +## Security Hygiene + +76. `app/Http/Controllers/Admin/ServerController.php:361` opens a new tab with `target="_blank"` and no `rel="noopener noreferrer"`, allowing reverse-tabnabbing. +77. `app/Http/Controllers/Admin/UserController.php:715` has the same reverse-tabnabbing issue. +78. `app/Http/Controllers/Admin/VoucherController.php:226` has the same reverse-tabnabbing issue. +79. `themes/BlueInfinity/views/layouts/main.blade.php:476` has the same reverse-tabnabbing issue. +80. `themes/BlueInfinity/views/layouts/main.blade.php:479` has the same reverse-tabnabbing issue. +81. `themes/BlueInfinity/views/layouts/main.blade.php:482` has the same reverse-tabnabbing issue. +82. `themes/default/views/admin/activitylogs/index.blade.php:32` has the same reverse-tabnabbing issue. +83. `themes/default/views/admin/store/create.blade.php:78` has the same reverse-tabnabbing issue. +84. `themes/default/views/admin/store/edit.blade.php:77` has the same reverse-tabnabbing issue. +85. `themes/default/views/admin/usefullinks/create.blade.php:45` has the same reverse-tabnabbing issue. +86. `themes/default/views/admin/usefullinks/edit.blade.php:47` has the same reverse-tabnabbing issue. +87. `themes/default/views/auth/login.blade.php:144` has the same reverse-tabnabbing issue. +88. `themes/default/views/auth/login.blade.php:147` has the same reverse-tabnabbing issue. +89. `themes/default/views/auth/login.blade.php:150` has the same reverse-tabnabbing issue. +90. `themes/default/views/auth/passwords/email.blade.php:94` has the same reverse-tabnabbing issue. +91. `themes/default/views/auth/passwords/email.blade.php:97` has the same reverse-tabnabbing issue. +92. `themes/default/views/auth/passwords/email.blade.php:100` has the same reverse-tabnabbing issue. +93. `themes/default/views/auth/passwords/reset.blade.php:88` has the same reverse-tabnabbing issue. +94. `themes/default/views/auth/passwords/reset.blade.php:91` has the same reverse-tabnabbing issue. +95. `themes/default/views/auth/passwords/reset.blade.php:94` has the same reverse-tabnabbing issue. +96. `themes/default/views/auth/register.blade.php:162` has the same reverse-tabnabbing issue. +97. `themes/default/views/auth/register.blade.php:194` has the same reverse-tabnabbing issue. +98. `themes/default/views/auth/register.blade.php:197` has the same reverse-tabnabbing issue. +99. `themes/default/views/auth/register.blade.php:200` has the same reverse-tabnabbing issue. +100. `themes/default/views/information/privacy-content.blade.php:6` has the same reverse-tabnabbing issue. +101. `themes/default/views/information/privacy-content.blade.php:103` has the same reverse-tabnabbing issue. +102. `themes/default/views/layouts/main.blade.php:474` has the same reverse-tabnabbing issue. +103. `themes/default/views/layouts/main.blade.php:477` has the same reverse-tabnabbing issue. +104. `themes/default/views/layouts/main.blade.php:480` has the same reverse-tabnabbing issue. +105. `themes/default/views/mail/server/suspended.blade.php:6` has the same reverse-tabnabbing issue. +106. `themes/default/views/mail/server/unsuspended.blade.php:6` has the same reverse-tabnabbing issue. +107. `themes/default/views/servers/index.blade.php:41` has the same reverse-tabnabbing issue. + +## Summary + +- Total findings: 107 +- Highest-risk areas: payment gateway state handling, environment-coupled gateway config, destructive model hooks, updater/import commands, and link security hygiene. +- Existing safe exception: `themes/default/views/information/privacy-content.blade.php:53` already includes `rel="external nofollow noopener"`. diff --git a/CODE_REVIEW_FINDINGS_2026-03-27_PASS2.md b/CODE_REVIEW_FINDINGS_2026-03-27_PASS2.md new file mode 100644 index 000000000..45a89de8d --- /dev/null +++ b/CODE_REVIEW_FINDINGS_2026-03-27_PASS2.md @@ -0,0 +1,81 @@ +# Code Review Findings (Second Pass) + +Status: archived review snapshot. The issues listed here were used as the basis for the follow-up fix pass on 2026-03-27. + +Additional findings beyond the archived first-pass review in `CODE_REVIEW_FINDINGS_2026-03-27.md`. + +Scope note: this is a static code review pass. Findings are line-referenced and concrete, but not all of them were reproduced end-to-end at runtime. + +1. High - `app/Traits/Referral.php:17-19` calls `generateReferralCode()` on collision, but the trait only defines `createReferralCode()`. The first referral-code collision will fatal. +2. High - `app/Traits/Coupon.php:74-75` uses `firstOrFail()` in `isCouponValid()`. An invalid or deleted coupon becomes an exception instead of a clean `false`. +3. High - `app/Traits/Coupon.php:98-111` dereferences `$coupon` without checking for `null`. If the coupon disappears between validation and application, checkout can crash. +4. High - `app/Traits/Coupon.php:100-111` returns raw float math for discounts even though prices are stored in integer milli-units. That can introduce rounding drift. +5. High - `app/Actions/ProcessReferralAction.php:46-51` inserts directly into `user_referrals` without checking for an existing row. Repeated execution can duplicate referral links. +6. High - `app/Actions/ProcessReferralAction.php:25-51` increments credits, queues a notification, logs activity, and inserts the referral row without a transaction. Partial referral state is possible. +7. Medium - `app/Actions/ProcessReferralAction.php:23-25` does not guard against self-referrals. If the action is reused outside signup, a user can reward themselves. +8. High - `app/Listeners/Verified.php:31-35` grants email-verification rewards without a transaction or lock. Duplicate event delivery can double-credit a user. +9. High - `app/Services/ServerCreationService.php:42,58` resolves `$egg` once and never validates it before calling `createServer()`. Reusing the service outside the current request validator can pass `null`. +10. High - `app/Services/ServerCreationService.php:67` deletes the local server before `pterodactyl_id` is set. The model delete hook still fires and issues a remote delete against an empty server ID. +11. High - `app/Services/ServerCreationService.php:72-75` assumes `response->json()['attributes']` exists. Malformed upstream responses become undefined-index errors. +12. High - `app/Services/ServerUpgradeService.php:106-117` calculates credit deltas as floats even though credits are stored as integer milli-units. Upgrades can overcharge or undercharge due to precision drift. +13. High - `app/Services/ServerUpgradeService.php:43-57,93-97` updates the remote Pterodactyl server before local billing and product changes are committed. Any later DB failure leaves local and remote state diverged. +14. High - `app/Services/ServerUpgradeService.php:43-57` never verifies that the target product is compatible with the server's current egg, node, or allocation. Service-level callers can request invalid upgrades. +15. Medium - `app/Rules/EggBelongsToProduct.php:36-42` dereferences `$this->data['product_id']` without guarding for a missing key. Bad validation context turns into an undefined-index error. +16. Medium - `app/Rules/ValidateEggVariables.php:45-56` assumes decoded egg metadata is an array of arrays containing `rules`, `default_value`, and `env_variable`. Corrupt egg metadata can trigger type errors. +17. Medium - `app/Helpers/CurrencyHelper.php:55-57` truncates with `(int) ($amount * 1000)` instead of rounding. Inputs like `0.015` lose value. +18. Medium - `app/Console/Commands/GetGithubVersion.php:35` performs an external GitHub request with no timeout. A slow network call can hang the command. +19. Medium - `app/Console/Commands/GetGithubVersion.php:35-36` assumes the GitHub tags API always returns `[0]['name']`. Rate limits or API changes can break the command with undefined indexes. +20. Medium - `app/Console/Commands/MakeUserCommand.php:69` returns `0` on validation failure. Symfony/Laravel commands expect non-zero exit codes for failures. +21. Medium - `app/Console/Commands/MakeUserCommand.php:86` returns `0` when the Pterodactyl lookup fails, again reporting failure as success to automation. +22. Medium - `app/Console/Commands/MakeUserCommand.php:93` returns `0` when required keys are missing from the upstream response. +23. Medium - `app/Console/Commands/MakeUserCommand.php:103` returns `0` when the local user already exists. +24. Medium - `app/Console/Commands/MakeUserCommand.php:132` returns `0` when the admin role is missing. +25. Medium - `app/Console/Commands/MakeUserCommand.php:137` returns `1` on success. Success and failure exit codes are inverted. +26. Medium - `app/Classes/PterodactylClient.php:150` assumes `getEggs()` always receives a JSON payload containing `data`. Unexpected response shapes crash the sync. +27. Medium - `app/Classes/PterodactylClient.php:169` assumes `getNodes()` always receives `data`. +28. Medium - `app/Classes/PterodactylClient.php:189` assumes `getNode()` always receives `attributes`. +29. Medium - `app/Classes/PterodactylClient.php:203` assumes `getServers()` always receives `data`. +30. Medium - `app/Classes/PterodactylClient.php:222` assumes `getNests()` always receives `data`. +31. Medium - `app/Classes/PterodactylClient.php:385` assumes `getUser()` always receives `attributes`. +32. High - `app/Classes/PterodactylClient.php:427-428` does `first()->delete()` in `getServerAttributes(..., deleteOn404: true)` with no null check. If the local row is already gone, the cleanup path crashes. +33. High - `app/Classes/PterodactylClient.php:486` sets `oom_disabled` to `$product->oom_killer` in `updateServerBuild()`, which is inverted relative to the create path's `!$server->product->oom_killer`. +34. Medium - `app/Classes/PterodactylClient.php:252` assumes the first free allocation always has `['attributes']['id']`. +35. Medium - `app/Classes/PterodactylClient.php:269` assumes every allocation includes `['attributes']['assigned']`. +36. High - `app/Classes/PterodactylClient.php:571-576` never checks `failed()` in `checkNodeResources()` before indexing the response body. 4xx/5xx responses can become array-access errors or bogus resource calculations. +37. Medium - `app/Classes/PterodactylClient.php:592` silently accepts invalid JSON in `getEnvironmentVariables()`. Bad payloads degrade into an empty collection instead of a validation error. +38. Medium - `app/Classes/PterodactylClient.php:600` merges arbitrary caller-supplied keys into the environment without whitelisting egg variables. Unexpected env vars can be sent upstream. +39. Medium - `app/Models/Pterodactyl/Egg.php:63-72` assumes every synced egg payload includes `relationships.variables.data`. Missing relationship data breaks sync. +40. Medium - `app/Models/Pterodactyl/Location.php:22-25` cascades node deletes one by one in model events with no transaction or chunking. Large sync removals can leave partial local state. +41. Medium - `app/Models/Pterodactyl/Nest.php:26-29` cascades egg deletes one by one in model events with no transaction or chunking. +42. Medium - `app/Models/Pterodactyl/Node.php:25-26` detaches products in a model event without a surrounding transaction. Partial detach state is possible if deletion fails mid-flight. +43. High - `app/Http/Controllers/Api/ServerController.php:152-159` sends the local `user_id` to Pterodactyl when changing ownership. Pterodactyl expects the remote user ID. +44. High - `app/Http/Controllers/Api/ServerController.php:148-165` updates Pterodactyl first and saves the local server afterward without a transaction. A local save failure leaves systems out of sync. +45. Medium - `app/Http/Controllers/Api/UserController.php:359-360` hardcodes Pterodactyl `external_id` to `"0"` for every created user, which destroys cross-system uniqueness. +46. Medium - `app/Http/Controllers/Api/UserController.php:379-380` assumes the create-user response always contains `json()['attributes']['id']`. +47. High - `app/Http/Controllers/Api/UserController.php:357-380` processes referral side effects before remote Pterodactyl user creation is confirmed. Notification jobs and side effects can escape before the external create succeeds. +48. High - `app/Http/Controllers/Api/UserController.php:452-457` inserts into `user_referrals` without checking for an existing row, allowing duplicate referral links through the API path. +49. High - `app/Http/Controllers/Api/UserController.php:447-452` increments credits and queues a referral notification outside a transaction with the referral insert. +50. Medium - `app/Http/Controllers/Admin/OverViewController.php:62,71` loads long payment ranges into memory and aggregates in PHP. The dashboard will not scale well. +51. Medium - `app/Http/Controllers/Admin/OverViewController.php:164` calls `getNode()` for every node after already fetching `getNodes()`, causing an avoidable N+1 remote API pattern on every overview request. +52. Medium - `app/Http/Controllers/Admin/OverViewController.php:173` divides average usage by the count of local DB nodes even when stale nodes were skipped. The displayed average is wrong when deleted remote nodes still exist locally. +53. High - `app/Http/Controllers/Admin/OverViewController.php:179-180` assumes every synced server still has a local product row. Missing products become null dereferences. +54. Medium - `app/Http/Controllers/Admin/OverViewController.php:175-191` performs per-server local DB lookups inside the remote server loop, creating another N+1-heavy path. +55. High - `app/Http/Controllers/Admin/OverViewController.php:199-201` assumes the latest ticket still has a user row. Deleted users break the overview widget. +56. Medium - `app/Listeners/AssociateDiscordRoles.php:29-30` counts all related servers, not active uncanceled servers, when deciding whether to assign the active-client Discord role. +57. Medium - `app/Listeners/DisassociateDiscordRoles.php:30-31` removes the active-client Discord role based on the total relation count, again ignoring canceled or otherwise inactive servers. +58. Medium - `app/Services/NotificationService.php:16-18` sends notifications to the full collection in one call with no chunking. Large "notify all" operations can spike memory and queue payload size. +59. Medium - `app/Support/HtmlSanitizer.php:13-29` allows `` tags but never normalizes `target`/`rel` attributes. Sanitized HTML can still preserve unsafe new-tab links. +60. High - `app/Http/Controllers/Auth/RegisterController.php:164` hardcodes `Role::findById(4)` for new registrations. Installs with different role IDs break signup role assignment. +61. High - `app/Http/Controllers/Auth/RegisterController.php:127-168` is not transactional across remote Pterodactyl creation, local user creation, role assignment, and referral processing. A local failure can orphan the remote user or leave partial referral state. +62. Medium - `app/Http/Controllers/Auth/RegisterController.php:129-130` creates remote users with `external_id => null`, so the local and remote accounts have no stable external linkage. +63. Medium - `app/Http/Controllers/Api/ServerController.php:100-112` accepts `description` in the request, but the creation path never persists it because `ServerCreationService` only writes `name`, `user_id`, `product_id`, `node_id`, `last_billed`, and `billing_priority`. +64. Medium - `app/Http/Controllers/ServerController.php:516-536` trusts client-supplied validation rules in `validateDeploymentVariables()` instead of authoritative egg metadata. Callers can fabricate rule sets and get meaningless validation results. +65. Medium - `app/Http/Controllers/ServerController.php:313-320` assigns the active-client Discord role based on the raw server relation count, not active uncanceled servers. +66. Medium - `app/Http/Controllers/ServerController.php:360-364` removes the active-client Discord role using the same raw count logic, so canceled servers can keep the role incorrectly. +67. Medium - `app/Models/Product.php:98-101` treats `0` as falsy in the `minimumCredits` setter, so explicitly setting a zero minimum is silently converted to `null`. +68. Medium - `app/Http/Requests/Api/Products/CreateProductRequest.php:39-47` validates integer-backed resource fields as `numeric` instead of `integer`. Fractional values can pass validation and then be silently truncated. +69. Medium - `app/Http/Requests/Api/Products/UpdateProductRequest.php:40-48` has the same issue on the update path. +70. Medium - `app/Http/Requests/Api/Vouchers/CreateVoucherRequest.php:28` validates `uses` as `numeric` instead of `integer`, so fractional use counts can pass validation. +71. Medium - `app/Http/Requests/Api/Vouchers/UpdateVoucherRequest.php:28` repeats the same issue on the update path. +72. High - `app/Traits/Invoiceable.php:26-27,81-89` derives invoice sequence numbers from `count()` and then writes the PDF and DB row separately. Concurrent invoice generation can produce duplicate invoice numbers and orphaned files. +73. Low - `app/Console/Commands/NotifyServerSuspension.php:67` returns `0` instead of `Command::SUCCESS`. That reports success only accidentally and is inconsistent with the rest of the command layer. diff --git a/SECURITY_AUDIT_FINDINGS.md b/SECURITY_AUDIT_FINDINGS.md new file mode 100644 index 000000000..622a9b7b0 --- /dev/null +++ b/SECURITY_AUDIT_FINDINGS.md @@ -0,0 +1,588 @@ +# Security Audit Findings - CtrlPanel.gg + +**Date:** March 23, 2026 +**Severity Classification:** +- 🔴 CRITICAL - Requires immediate fix +- 🟠 HIGH - Fix urgently, affects security +- 🟡 MEDIUM - Should be fixed soon +- 🔵 LOW - Code quality/best practices + +--- + +## CRITICAL VULNERABILITIES + +### 1. 🔴 Weak Random ID Generation in Payment Processing + +**Location:** +- `app/Extensions/PaymentGateways/PayPal/PayPalExtension.php` line 50 +- `app/Http/Controllers/Admin/PaymentController.php` line 112 + +**Issue:** Uses `uniqid()` for generating payment reference IDs and payment_id values. `uniqid()` is NOT cryptographically secure and can be predicted/guessed. + +```php +// VULNERABLE +"reference_id" => uniqid(), +'payment_id' => uniqid(), +``` + +**Impact:** Attackers could manipulate payment IDs or forge payment references. + +**Fix:** Use `Illuminate\Support\Str::random()` or `random_bytes()` instead. + +```php +"reference_id" => \Illuminate\Support\Str::uuid(), // or random(16) +'payment_id' => \Illuminate\Support\Str::random(32), +``` + +--- + +### 2. 🔴 API Authorization Bypass via Role Manipulation + +**Location:** `app/Http/Controllers/Api/UserController.php` line ~115 + +**Issue:** The API allows role_id updates for scoped tokens with **ensureCanAccessUser** check passing but no verification that the token owner is authorized to change roles. + +```php +if ($this->ownerScopedUserId($request) !== null && isset($data['role_id'])) { + abort(403, 'This API token cannot change roles.'); +} +``` + +This check only works if the token is owner-scoped. A **global API token** with `users.write` can change ANY user's role, including promoting regular users to admin. + +**Impact:** Privilege escalation - API users could become admins. + +**Fix:** Ensure global tokens also validate role change authorization: + +```php +// Check if user changing role is admin +$this->authorize('admin.users.write.role'); +``` + +--- + +### 3. 🔴 Admin Bypass via Missing Authorization Check in Admin Controllers + +**Location:** `app/Http/Controllers/Admin/PaymentController.php` line 112-130 + +**Issue:** Manual payment creation endpoint doesn't validate the user actually has permission to create payments. While there's an `authorize()` check missing, the more critical issue is: + +The payment creation allows admin-only manual payment entry without proper authorization checks across payment types. + +**Impact:** Users with limited admin permissions could create fraudulent payments. + +**Fix:** Add explicit permission checks in PaymentController methods. + +--- + +### 4. 🔴 Unencrypted Sensitive Data in Activity Logs + +**Location:** `app/Models/User.php` line 130-140 +**Location:** `app/Models/Voucher.php` tapping activity +**Location:** `app/Models/Server.php` LogsActivity trait + +**Issue:** Activity log system logs user mutations including potentially sensitive fields. While `$logAttributes` appears limited, the `CausesActivity` trait could log credential data if not properly configured. + +```php +protected static $logAttributes = ['name', 'email']; +``` + +Email changes ARE being logged to activity logs which are stored in database without encryption. + +**Impact:** Sensitive user data exposed in audit logs. + +**Fix:** +- Move sensitive data to separate encrypted table +- Use masking/hashing for email in logs +- Implement log purge policies + +--- + +### 5. 🔴 Race Condition in Credit/Voucher Consumption + +**Location:** `app/Models/Voucher.php` line 149 +**Location:** `app/Console/Commands/ChargeServers.php` billing logic + +**Issue:** While pessimistic locking (lockForUpdate) is used in some places, the voucher redemption and server billing system has a time-of-check to time-of-use (TOCTOU) vulnerability. + +The check for voucher validity happens in `isCouponValid()` but the actual consumption happens later, allowing potential double-redemption race conditions. + +**Impact:** Users could use coupons multiple times or servers could be charged twice. + +**Fix:** Use database transactions with proper locking: + +```php +DB::transaction(function() { + $voucher = Voucher::lockForUpdate()->find($id); + // Validate and consume atomically +}, 3); // 3 retries for deadlocks +``` + +--- + +## HIGH SEVERITY VULNERABILITIES + +### 6. 🟠 Missing CSRF Protection on Webhook Routes + +**Location:** `app/Http/Middleware/VerifyCsrfToken.php` + +**Issue:** Webhook routes are correctly excluded from CSRF verification via `RoutesIgnoreCsrf` config. However, there's no verification of webhook authenticity (no signature validation visible for at least Mollie/Stripe). + +While payment gateways handle signature validation in their SDK, the exception list is dynamically built, creating a potential for misconfig. + +**Impact:** Unverified webhooks could cause payment state corruption. + +**Fix:** Add explicit webhook signature verification in all gateway handlers. + +--- + +### 7. 🟠 API Scope Override Potential + +**Location:** `app/Http/Middleware/ApplicationApiScope.php` + +**Issue:** The scope checking uses `hasAnyAbility()` which checks against token abilities. However, there's no lifecycle enforcement on API token expiration or revocation check. + +Once an `expires_at` date passes, `isActive()` returns false, but there's no explicit background revocation of expired tokens, and they remain queryable. + +**Impact:** Expired tokens might be replayed if not strictly enforced. + +**Fix:** Add automatic token purging: + +```php +// In middleware +if (!$token->isActive()) { + $token->forceDelete(); // Or soft delete + abort(401); +} +``` + +--- + +### 8. 🟠 Mass Assignment Vulnerability in Admin Controllers + +**Location:** Multiple admin controllers, e.g., `app/Http/Controllers/Admin/ShopProductController.php` line 79, 123 + +**Issue:** Direct use of `array_merge($request->all(), ...)` without explicitly validating which fields got through. + +```php +ShopProduct::create(array_merge($request->all(), ['disabled' => $disabled])); +$shopProduct->update(array_merge($request->all(), ['disabled' => $disabled])); +``` + +While models have `$fillable` arrays defined, explicitly using `all()` could catch unintended fields if validation is bypassed. + +**Impact:** Unintended field modification. + +**Fix:** Use `only()` explicitly: + +```php +$shopProduct->update($request->validated()); +``` + +--- + +### 9. 🟠 Unencrypted Database Credentials in Environment Variables + +**Location:** `.env.example` + config/database.php + +**Issue:** While this is expected for example file, there's no indication of database encryption at rest or SQL file backups being encrypted. + +**Impact:** Database backup exposure leaks all user/payment data. + +**Fix:** +- Document database encryption requirements +- Enforce encrypted backups +- Use read replicas with restricted access + +--- + +### 10. 🟠 SQL Injection Risk via DB::raw() in Migrations/Queries + +**Location:** `database/migrations/2026_02_02_175351_migrate_product_minimum_credits_values.php` line 32 + +**Issue:** While not user input, the use of `DB::raw()` could be dangerous if any part becomes dynamic: + +```php +->where('type', 'free') +->update(['minimum_credits' => DB::raw('price')]); +``` + +This is safe as-is, but pattern encourages risky usage elsewhere. + +**Impact:** Potential SQL injection if this pattern spreads. + +**Fix:** Use parameter binding instead: + +```php +// Use raw only when absolutely necessary, never with user input +// Document the reason for using raw +``` + +--- + +## MEDIUM SEVERITY ISSUES + +### 11. 🟡 Missing Rate Limiting on API Endpoints + +**Location:** `routes/api.php` + +**Issue:** API routes lack rate limiting middleware. Anyone with a valid token can make unlimited requests. + +**Example:** +```php +Route::middleware('api.token')->group(function () { + Route::get('users', [UserController::class, 'index']) + // No rate limiting! +}); +``` + +**Impact:** API abuse, DOS attacks on database queries. + +**Fix:** Add rate limiting: + +```php +Route::middleware(['api.token', 'throttle:60,1'])->group(...) +``` + +--- + +### 12. 🟡 Missing Input Validation on Filters + +**Location:** `app/Http/Controllers/Api/UserController.php` line 56-64 + +**Issue:** API uses `QueryBuilder::allowedFilters()` which restricts field names, but user input like `name` is passed directly to query builder. + +While the library prevents field traversal, complex filter chains could cause database load issues. + +**Impact:** Resource exhaustion attacks. + +**Fix:** Add pagination limits and query complexity limits: + +```php +->paginate(min($request->input('per_page') ?? 50, 100)) +``` + +--- + +### 13. 🟡 Weak Gravatar Hash Dependency + +**Location:** `app/Models/User.php` line 297 + +**Issue:** Uses MD5 for Gravatar URL generation. While MD5 is acceptable for Gravatar (it's their standard), it indicates older security practices in codebase. + +**Impact:** Low - but indicates legacy security thinking. + +**Fix:** No fix needed for Gravatar (it's their standard), but document this exception. + +--- + +### 14. 🟡 Missing HEADERS Security Headers + +**Location:** HTTP response headers configuration + +**Issue:** No evidence of security headers configuration (X-Frame-Options, X-Content-Type-Options, etc) + +**Impact:** XSS, clickjacking, MIME sniffing attacks. + +**Fix:** Add middleware: + +```php +// config/http.php or middleware +'X-Frame-Options' => 'DENY', +'X-Content-Type-Options' => 'nosniff', +'X-XSS-Protection' => '1; mode=block', +'Strict-Transport-Security' => 'max-age=31536000' +``` + +--- + +### 15. 🟡 Insecure Redirect Potential + +**Location:** `app/Extensions/PaymentGateways/PayPal/PayPalExtension.php` line 60-65 + +**Issue:** Success/cancel URLs are constructed using route() helper, which is safe, but there's no validation that users are redirected to their own payment checkout, not another user's. + +Actually, checking line 100+ shows proper user_id verification, so this is safe. + +**Noted:** Code does verify payment ownership correctly. + +--- + +### 16. 🟡 Sensitive Data in Logs + +**Location:** `app/Listeners/UserPayment.php` and other listeners + +**Issue:** Logging with full details of payments and users without masking PII. + +```php +logger()->warning('Failed to update user in Pterodactyl.', [ + 'user_id' => $user->id, // OK + 'status' => $response->status(), +]); +``` + +Better, but more complex operations log full objects. + +**Impact:** Sensitive data in log files. + +**Fix:** Mask PII in logs: + +```php +logger()->warning('Payment processing error', [ + 'user_id' => auth()->id(), + 'payment_hash' => hash('sha256', $payment->id), +]); +``` + +--- + +### 17. 🟡 No Audit Trail for API Operations + +**Location:** `app/Http/Controllers/Api/*` + +**Issue:** API operations don't trigger activity logging like admin operations do. An API user could make massive changes without audit trail. + +**Impact:** Untrackable malicious API usage. + +**Fix:** Add activity logging to API controllers via middleware or trait. + +--- + +### 18. 🟡 Inadequate Payment Status Validation + +**Location:** `app/Extensions/PaymentGateways/Stripe/StripeExtension.php` line 127-160 + +**Issue:** Payment status update only checks for non-PAID status: + +```php +$updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([...]); +``` + +This allows PROCESSING status payments to be marked as PAID, which is correct. However, there's no verification that the amount matches what was requested. + +**Impact:** Could cause payment amount mismatches. + +**Fix:** Verify payment amount matches: + +```php +if ((int)$paymentIntent->amount_received !== $payment->amount_integer) { + abort(400, 'Amount mismatch'); +} +``` + +--- + +### 19. 🟡 No Backup/Recovery Plan for API Tokens + +**Location:** `app/Models/ApplicationApi.php` + +**Issue:** Once an API token is created and shown once, there's no recovery mechanism if lost. This is OK for security, but dangerous operationally. + +**Impact:** Lost access could lock out API consumers. + +**Fix:** Document token backup requirements for users. + +--- + +--- + +## LOW SEVERITY ISSUES + +### 20. 🔵 Deprecated md5() Usage for Gravatar + +Already mentioned in Medium section - acceptable for Gravatar but elsewhere it's weak. + +--- + +### 21. 🔵 Missing @throws Documentation + +**Location:** Multiple controllers + +**Issue:** Methods using findOrFail() don't document ModelNotFoundException in docblocks, though types hint it. + +**Impact:** Developer confusion. + +**Fix:** Add proper docblock documentation. + +--- + +### 22. 🔵 Inconsistent Error Handling + +**Location:** Multiple payment gateways + +**Issue:** Some gateways log failed responses, others might not. Inconsistent error handling patterns make maintenance harder. + +**Impact:** Potential missed errors in production. + +**Fix:** Create unified payment error handler. + +--- + +### 23. 🔵 Permission String Typos Potential + +**Location:** `config/permissions_web.php` and usage throughout + +**Issue:** Permission strings are magic strings. A typo could silently fail permission checks. + +**Impact:** Accidental authorization bypass if permission names are mistyped. + +**Fix:** Create constants for permission names: + +```php +const ADMIN_USERS_READ = 'admin.users.read'; +const ADMIN_USERS_WRITE = 'admin.users.write'; +``` + +--- + +### 24. 🔵 No Request/Response Logging for API + +**Location:** `routes/api.php` + +**Issue:** No middleware to log API requests/responses for debugging. + +**Impact:** Hard to debug production issues. + +**Fix:** Add debug logging middleware. + +--- + +### 25. 🔵 Timezone Issues Possible in Scheduling + +**Location:** `app/Console/Commands/*` + +**Issue:** Commands use Carbon for date comparison. If timezone isn't properly set, could cause off-by-one billing. + +```php +Carbon::now(config('app.timezone')) +``` + +This is actually done correctly! Good practice observed. + +**Noted:** This is handled properly. + +--- + +--- + +## ADDITIONAL OBSERVATIONS & BEST PRACTICES + +### ✅ Strengths Observed: + +1. **Proper use of Pessimistic Locking** - Server charging uses `lockForUpdate()` +2. **Strong Password Hashing** - Uses Laravel's Hash facade (bcrypt) +3. **CSRF Protection** - Properly configured middleware with extension exceptions +4. **Authorization Checks** - Most routes check permissions via `$this->checkPermission()` +5. **API Scope Validation** - Token scopes are properly validated +6. **SQL Prepared Statements** - Consistent use of parameter binding in queries +7. **Activity Logging** - Spatie activity log captures changes +8. **Database Transactions** - Used correctly in critical operations + +### ⚠️ Recommended Improvements: + +1. Implement **API rate limiting** globally +2. Add **request signing** for extra-sensitive operations +3. Implement **circuit breaker** for Pterodactyl API calls +4. Add **audit event webhooks** for security events +5. Implement **two-factor authentication** for admin accounts +6. Add **security headers** to all responses +7. Encrypt **sensitive configuration** values +8. Implement **IP whitelisting** for admin area +9. Add **SIEM integration** for security events +10. Implement **database activity monitoring** + +--- + +## QUICK PRIORITY FIX LIST + +**Fix immediately (Next 24 hours):** +1. ✅ Replace `uniqid()` with cryptographically secure random +2. ✅ Add authorization check for global token role changes +3. ✅ Verify webhook signatures explicitly +4. ✅ Add payment amount verification + +**Fix this week:** +5. Add rate limiting to API +6. Add security headers middleware +7. Implement API operation logging +8. Fix race condition in voucher/billing + +**Fix this sprint:** +9. Encrypt sensitive data in activity logs +10. Add IP whitelisting for admin area +11. Implement 2FA for admins +12. Add request signing for critical operations + +--- + +## APPENDIX: Code Examples for Fixes + +### Fix 1: Replace uniqid() + +```php +// BEFORE +'payment_id' => uniqid(), + +// AFTER +'payment_id' => Str::random(16), +// OR +'payment_id' => (string) Uuid::generate(), +``` + +### Fix 2: Add Security Headers + +```php +// app/Http/Middleware/SecurityHeaders.php +public function handle(Request $request, Closure $next): Response +{ + $response = $next($request); + + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + if (config('app.env') === 'production') { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + return $response; +} + +// Register in Kernel.php +protected $middleware = [ + // ... + \App\Http\Middleware\SecurityHeaders::class, +]; +``` + +### Fix 3: Add API Rate Limiting + +```php +// routes/api.php +Route::middleware(['api.token', 'throttle:60,1'])->group(function () { + // API routes +}); +``` + +### Fix 4: Verify Payment Amount + +```php +// In Stripe/PayPal webhook handlers +if ((int)$remotePaymentAmount !== (int)$payment->amount_integer) { + Log::critical('Payment amount mismatch', [ + 'payment_id' => $payment->id, + 'expected' => $payment->amount_integer, + 'received' => $remotePaymentAmount, + ]); + abort(400, 'Amount verification failed'); +} +``` + +--- + +**Report Generated:** 2026-03-23 +**Auditor:** Security Analysis +**Status:** Review Complete - 25 Issues Found diff --git a/app/Actions/ProcessReferralAction.php b/app/Actions/ProcessReferralAction.php index fe24ec678..1d5a81df1 100644 --- a/app/Actions/ProcessReferralAction.php +++ b/app/Actions/ProcessReferralAction.php @@ -20,12 +20,33 @@ public function __construct( public function execute(User $user, string $referral_code, bool $log_activity = false) { - $ref_user = User::query()->where('referral_code', $referral_code)->first(); + return DB::transaction(function () use ($user, $referral_code, $log_activity) { + $refUser = User::query() + ->where('referral_code', $referral_code) + ->lockForUpdate() + ->first(); + + if ($refUser === null || $refUser->id === $user->id) { + return false; + } + + $existingReferral = DB::table('user_referrals') + ->where('registered_user_id', $user->id) + ->exists(); + + if ($existingReferral) { + return false; + } + + DB::table('user_referrals')->insert([ + 'referral_id' => $refUser->id, + 'registered_user_id' => $user->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); - if ($ref_user) { if ($this->referralSettings->mode === 'sign-up' || $this->referralSettings->mode === 'both') { - $ref_user->increment('credits', $this->referralSettings->reward); - $ref_user->notify(new ReferralNotification($user)); + $refUser->increment('credits', $this->referralSettings->reward); if ($log_activity) { $log = sprintf( @@ -38,17 +59,16 @@ public function execute(User $user, string $referral_code, bool $log_activity = activity() ->performedOn($user) - ->causedBy($ref_user) + ->causedBy($refUser) ->log($log); } + + DB::afterCommit(static function () use ($refUser, $user): void { + $refUser->notify(new ReferralNotification($user)); + }); } - DB::table('user_referrals')->insert([ - 'referral_id' => $ref_user->id, - 'registered_user_id' => $user->id, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } + return true; + }); } -} \ No newline at end of file +} diff --git a/app/Classes/PterodactylClient.php b/app/Classes/PterodactylClient.php index 2ecbad68c..8af40d972 100644 --- a/app/Classes/PterodactylClient.php +++ b/app/Classes/PterodactylClient.php @@ -24,9 +24,11 @@ class PterodactylClient private int $allocation_limit = 200; - public PendingRequest $client; + public ?PendingRequest $client = null; - public PendingRequest $application; + public ?PendingRequest $application = null; + + private ?Exception $constructionException = null; public function __construct(PterodactylSettings $ptero_settings) { @@ -38,6 +40,7 @@ public function __construct(PterodactylSettings $ptero_settings) $this->per_page_limit = $ptero_settings->per_page_limit; $this->allocation_limit = $server_settings->allocation_limit; } catch (Exception $exception) { + $this->constructionException = $exception; logger('Failed to construct Pterodactyl client, Settings table not available?', ['exception' => $exception]); } } @@ -50,7 +53,7 @@ public function client(PterodactylSettings $ptero_settings) 'Authorization' => 'Bearer ' . $ptero_settings->user_token, 'Content-type' => 'application/json', 'Accept' => 'Application/vnd.pterodactyl.v1+json', - ])->baseUrl($ptero_settings->getUrl() . 'api' . '/'); + ])->timeout(30)->connectTimeout(10)->baseUrl($ptero_settings->getUrl() . 'api' . '/'); } public function clientAdmin(PterodactylSettings $ptero_settings) @@ -59,7 +62,7 @@ public function clientAdmin(PterodactylSettings $ptero_settings) 'Authorization' => 'Bearer ' . $ptero_settings->admin_token, 'Content-type' => 'application/json', 'Accept' => 'Application/vnd.pterodactyl.v1+json', - ])->baseUrl($ptero_settings->getUrl() . 'api' . '/'); + ])->timeout(30)->connectTimeout(10)->baseUrl($ptero_settings->getUrl() . 'api' . '/'); } /** @@ -95,6 +98,38 @@ private function getException(string $message = '', ?int $status = null): HttpEx return new Exception('Request Failed, is pterodactyl set-up correctly? - ' . $message); } + /** + * @throws Exception + */ + private function applicationRequest(): PendingRequest + { + if ($this->constructionException) { + throw new Exception('Failed to construct Pterodactyl client.', 0, $this->constructionException); + } + + if (! $this->application) { + throw new Exception('Pterodactyl application client is not configured.'); + } + + return $this->application; + } + + /** + * @throws Exception + */ + private function clientRequest(): PendingRequest + { + if ($this->constructionException) { + throw new Exception('Failed to construct Pterodactyl client.', 0, $this->constructionException); + } + + if (! $this->client) { + throw new Exception('Pterodactyl client is not configured.'); + } + + return $this->client; + } + /** * @param Nest $nest * @return mixed @@ -104,7 +139,7 @@ private function getException(string $message = '', ?int $status = null): HttpEx public function getEggs(Nest $nest) { try { - $response = $this->application->get("application/nests/{$nest->id}/eggs?include=nest,variables&per_page=" . $this->per_page_limit); + $response = $this->applicationRequest()->get("application/nests/{$nest->id}/eggs?include=nest,variables&per_page=" . $this->per_page_limit); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -112,7 +147,7 @@ public function getEggs(Nest $nest) throw self::getException('Failed to get eggs from pterodactyl - ', $response->status()); } - return $response->json()['data']; + return $this->extractRequiredArray($response, 'data', 'Failed to parse eggs response from Pterodactyl.'); } /** @@ -123,7 +158,7 @@ public function getEggs(Nest $nest) public function getNodes() { try { - $response = $this->application->get('application/nodes?per_page=' . $this->per_page_limit); + $response = $this->applicationRequest()->get('application/nodes?per_page=' . $this->per_page_limit); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -131,7 +166,7 @@ public function getNodes() throw self::getException('Failed to get nodes from pterodactyl - ', $response->status()); } - return $response->json()['data']; + return $this->extractRequiredArray($response, 'data', 'Failed to parse nodes response from Pterodactyl.'); } /** @@ -143,7 +178,7 @@ public function getNodes() public function getNode($id) { try { - $response = $this->application->get('application/nodes/' . $id); + $response = $this->applicationRequest()->get('application/nodes/' . $id); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -151,13 +186,13 @@ public function getNode($id) throw self::getException('Failed to get node id ' . $id . ' - ' . $response->status()); } - return $response->json()['attributes']; + return $this->extractRequiredArray($response, 'attributes', 'Failed to parse node response from Pterodactyl.'); } public function getServers() { try { - $response = $this->application->get('application/servers?per_page=' . $this->per_page_limit); + $response = $this->applicationRequest()->get('application/servers?per_page=' . $this->per_page_limit); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -165,7 +200,7 @@ public function getServers() throw self::getException('Failed to get list of servers - ', $response->status()); } - return $response->json()['data']; + return $this->extractRequiredArray($response, 'data', 'Failed to parse servers response from Pterodactyl.'); } /** @@ -176,7 +211,7 @@ public function getServers() public function getNests() { try { - $response = $this->application->get('application/nests?per_page=' . $this->per_page_limit); + $response = $this->applicationRequest()->get('application/nests?per_page=' . $this->per_page_limit); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -184,7 +219,7 @@ public function getNests() throw self::getException('Failed to get nests from pterodactyl', $response->status()); } - return $response->json()['data']; + return $this->extractRequiredArray($response, 'data', 'Failed to parse nests response from Pterodactyl.'); } /** @@ -195,7 +230,7 @@ public function getNests() public function getLocations() { try { - $response = $this->application->get('application/locations?per_page=' . $this->per_page_limit); + $response = $this->applicationRequest()->get('application/locations?per_page=' . $this->per_page_limit); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -203,7 +238,7 @@ public function getLocations() throw self::getException('Failed to get locations from pterodactyl - ', $response->status()); } - return $response->json()['data']; + return $this->extractRequiredArray($response, 'data', 'Failed to parse locations response from Pterodactyl.'); } /** @@ -214,7 +249,15 @@ public function getLocations() */ public function getFreeAllocationId(Node $node) { - return self::getFreeAllocations($node)[0]['attributes']['id'] ?? null; + foreach ($this->getFreeAllocations($node) as $allocation) { + $allocationId = data_get($allocation, 'attributes.id'); + + if (is_int($allocationId) || ctype_digit((string) $allocationId)) { + return (int) $allocationId; + } + } + + return null; } /** @@ -228,12 +271,14 @@ public function getFreeAllocations(Node $node) $response = self::getAllocations($node); $freeAllocations = []; - if (isset($response['data'])) { - if (!empty($response['data'])) { - foreach ($response['data'] as $allocation) { - if (!$allocation['attributes']['assigned']) { - array_push($freeAllocations, $allocation); - } + if (isset($response['data']) && is_array($response['data'])) { + foreach ($response['data'] as $allocation) { + if (! is_array($allocation)) { + continue; + } + + if (! (bool) data_get($allocation, 'attributes.assigned', true)) { + $freeAllocations[] = $allocation; } } } @@ -250,7 +295,7 @@ public function getFreeAllocations(Node $node) public function getAllocations(Node $node) { try { - $response = $this->application->get("application/nodes/{$node->id}/allocations?per_page={$this->allocation_limit}"); + $response = $this->applicationRequest()->get("application/nodes/{$node->id}/allocations?per_page={$this->allocation_limit}"); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -258,7 +303,13 @@ public function getAllocations(Node $node) throw self::getException('Failed to get allocations from pterodactyl - ', $response->status()); } - return $response->json(); + $payload = $response->json(); + + if (! is_array($payload)) { + throw new Exception('Failed to parse allocations response from Pterodactyl.'); + } + + return $payload; } /** @@ -270,7 +321,7 @@ public function getAllocations(Node $node) public function createServer(Server $server, Egg $egg, int $allocationId, mixed $eggVariables = null) { try { - $response = $this->application->post('application/servers', [ + $response = $this->applicationRequest()->post('application/servers', [ 'name' => $server->name, 'external_id' => $server->id, 'user' => $server->user->pterodactyl_id, @@ -305,7 +356,7 @@ public function createServer(Server $server, Egg $egg, int $allocationId, mixed public function suspendServer(Server $server) { try { - $response = $this->application->post("application/servers/$server->pterodactyl_id/suspend"); + $response = $this->applicationRequest()->post("application/servers/$server->pterodactyl_id/suspend"); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -319,7 +370,7 @@ public function suspendServer(Server $server) public function unSuspendServer(Server $server) { try { - $response = $this->application->post("application/servers/$server->pterodactyl_id/unsuspend"); + $response = $this->applicationRequest()->post("application/servers/$server->pterodactyl_id/unsuspend"); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -339,7 +390,7 @@ public function unSuspendServer(Server $server) public function getUser(int $pterodactylId) { try { - $response = $this->application->get("application/users/{$pterodactylId}"); + $response = $this->applicationRequest()->get("application/users/{$pterodactylId}"); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -347,7 +398,7 @@ public function getUser(int $pterodactylId) throw self::getException('Failed to get user from pterodactyl - ', $response->status()); } - return $response->json()['attributes']; + return $this->extractRequiredArray($response, 'attributes', 'Failed to parse user response from Pterodactyl.'); } /** @@ -361,7 +412,7 @@ public function getUser(int $pterodactylId) public function updateUser(int $pterodactylId, array $data) { try { - $response = $this->application->patch("application/users/{$pterodactylId}", $data); + $response = $this->applicationRequest()->patch("application/users/{$pterodactylId}", $data); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -381,7 +432,7 @@ public function updateUser(int $pterodactylId, array $data) public function getServerAttributes(int $pterodactylId, bool $deleteOn404 = false) { try { - $response = $this->application->get("application/servers/{$pterodactylId}?include=egg,node,nest,location"); + $response = $this->applicationRequest()->get("application/servers/{$pterodactylId}?include=egg,node,nest,location"); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -389,16 +440,20 @@ public function getServerAttributes(int $pterodactylId, bool $deleteOn404 = fals //print response body if ($response->failed()) { - if ($deleteOn404) { //Delete the server if it does not exist (server deleted on pterodactyl) - Server::where('pterodactyl_id', $pterodactylId)->first()->delete(); + if ($deleteOn404 && $response->status() === 404) { //Delete the server if it does not exist (server deleted on pterodactyl) + $server = Server::query()->where('pterodactyl_id', $pterodactylId)->first(); + + if ($server) { + $server->deleteQuietly(); + } - return; + return null; } else { throw self::getException('Failed to get server attributes from pterodactyl - ', $response->status()); } } - return $response->json()['attributes']; + return $this->extractRequiredArray($response, 'attributes', 'Failed to parse server response from Pterodactyl.'); } /** @@ -412,7 +467,7 @@ public function getServerAttributes(int $pterodactylId, bool $deleteOn404 = fals */ public function updateServer(Server $server, Product $product) { - return $this->application->patch("application/servers/{$server->pterodactyl_id}/build", [ + return $this->applicationRequest()->patch("application/servers/{$server->pterodactyl_id}/build", [ 'allocation' => $server->allocation, 'memory' => $product->memory, 'swap' => $product->swap, @@ -440,7 +495,7 @@ public function updateServer(Server $server, Product $product) public function updateServerBuild(string $pterodactylId, int $pterodactylAllocation, Product $product) { try { - $response = $this->application->patch("application/servers/{$pterodactylId}/build", [ + $response = $this->applicationRequest()->patch("application/servers/{$pterodactylId}/build", [ 'allocation' => $pterodactylAllocation, 'memory' => $product->memory, 'swap' => $product->swap, @@ -448,7 +503,7 @@ public function updateServerBuild(string $pterodactylId, int $pterodactylAllocat 'io' => $product->io, 'cpu' => $product->cpu, 'threads' => null, - 'oom_disabled' => $product->oom_killer, + 'oom_disabled' => !$product->oom_killer, 'feature_limits' => [ 'databases' => $product->databases, 'backups' => $product->backups, @@ -457,7 +512,7 @@ public function updateServerBuild(string $pterodactylId, int $pterodactylAllocat ]); if ($response->failed()) { - throw self::getException('Server not found on Pterodactyl', 404); + throw self::getException('Failed to update server build on Pterodactyl.', $response->status()); } return $response; @@ -475,7 +530,7 @@ public function updateServerBuild(string $pterodactylId, int $pterodactylAllocat */ public function updateServerOwner(Server $server, int $userId) { - return $this->application->patch("application/servers/{$server->pterodactyl_id}/details", [ + return $this->applicationRequest()->patch("application/servers/{$server->pterodactyl_id}/details", [ 'name' => $server->name, 'user' => $userId, ]); @@ -494,7 +549,7 @@ public function updateServerOwner(Server $server, int $userId) public function updateServerDetails(Server $server, array $data) { try { - return $this->application->patch("application/servers/{$server->pterodactyl_id}/details", $data); + return $this->applicationRequest()->patch("application/servers/{$server->pterodactyl_id}/details", $data); } catch (Exception $e) { throw self::getException($e->getMessage()); } @@ -509,7 +564,7 @@ public function updateServerDetails(Server $server, array $data) */ public function powerAction(Server $server, $action) { - return $this->client->post("client/servers/{$server->identifier}/power", [ + return $this->clientRequest()->post("client/servers/{$server->identifier}/power", [ 'signal' => $action, ]); } @@ -519,7 +574,7 @@ public function powerAction(Server $server, $action) */ public function getClientUser() { - return $this->client->get('client/account'); + return $this->clientRequest()->get('client/account'); } /** @@ -533,11 +588,16 @@ public function getClientUser() public function checkNodeResources(Node $node, int $requireMemory, int $requireDisk) { try { - $response = $this->application->get("application/nodes/{$node->id}"); + $response = $this->applicationRequest()->get("application/nodes/{$node->id}"); } catch (Exception $e) { throw self::getException($e->getMessage()); } - $node = $response['attributes']; + + if ($response->failed()) { + throw self::getException('Failed to get node resources from pterodactyl - ', $response->status()); + } + + $node = $this->extractRequiredArray($response, 'attributes', 'Failed to parse node resources from Pterodactyl.'); $freeMemory = ($node['memory'] * ($node['memory_overallocate'] + 100) / 100) - $node['allocated_resources']['memory']; $freeDisk = ($node['disk'] * ($node['disk_overallocate'] + 100) / 100) - $node['allocated_resources']['disk']; if ($freeMemory < $requireMemory) { @@ -553,17 +613,53 @@ public function checkNodeResources(Node $node, int $requireMemory, int $requireD private function getEnvironmentVariables(Egg $egg, $variables) { $environment = []; - // Support for front-end and api variables format. - $variables = collect(is_string($variables) ? json_decode($variables, true) : $variables); + $allowedVariables = []; + + if (is_string($variables)) { + $decodedVariables = json_decode($variables, true); + $variables = is_array($decodedVariables) ? $decodedVariables : []; + } + + $variables = is_array($variables) ? $variables : []; + + foreach ((array) $egg->environment as $envVariable) { + if (! is_array($envVariable) || empty($envVariable['env_variable'])) { + continue; + } + + $allowedVariables[] = $envVariable['env_variable']; - foreach ($egg->environment as $envVariable) { - if (!empty($envVariable['default_value'])) { + if (array_key_exists('default_value', $envVariable) && $envVariable['default_value'] !== null && $envVariable['default_value'] !== '') { $environment[$envVariable['env_variable']] = $envVariable['default_value']; } } - $environment = array_merge($environment, $variables->toArray()); + foreach ($variables as $key => $value) { + if (in_array($key, $allowedVariables, true)) { + $environment[$key] = $value; + } + } return $environment; } + + /** + * @throws Exception + */ + private function extractRequiredArray(Response $response, string $path, string $message): array + { + $payload = $response->json(); + + if (! is_array($payload)) { + throw new Exception($message); + } + + $value = data_get($payload, $path); + + if (! is_array($value)) { + throw new Exception($message); + } + + return $value; + } } diff --git a/app/Console/Commands/ChargeServers.php b/app/Console/Commands/ChargeServers.php index c713df4e5..2724405a0 100644 --- a/app/Console/Commands/ChargeServers.php +++ b/app/Console/Commands/ChargeServers.php @@ -96,33 +96,58 @@ public function handle() } - $isCanceled = $server->canceled; - $hasInsufficientCredits = $user->credits < $product->price && $product->price != 0; - - // check if the server is canceled or if user has enough credits to charge the server - if ($isCanceled || $hasInsufficientCredits) { - try { - $this->suspendFunc($server, $user); - } catch (Exception $exception) { - $this->error($exception->getMessage()); - } - } else { - // charge credits to user - $this->line("{$user->name} Current credits: {$user->credits} Credits to be removed: {$product->price}"); - - if ($user->credits >= $product->price) { - $user->decrement('credits', $product->price); - $user->refresh(); - // update server last_billed date in db - DB::table('servers')->where('id', $server->id)->update(['last_billed' => $newBillingDate]); - } else { + try { + $chargeResult = DB::transaction(function () use ($server) { + $lockedServer = Server::query()->lockForUpdate()->with(['product', 'user'])->find($server->id); + + if (! $lockedServer || $lockedServer->suspended !== null) { + return 'skip'; + } + + $lockedProduct = $lockedServer->product; + $lockedUser = User::query()->lockForUpdate()->findOrFail($lockedServer->user_id); + + $nextBillingDate = match ($lockedProduct->billing_period) { + 'annually' => Carbon::parse($lockedServer->last_billed)->addYear(), + 'half-annually' => Carbon::parse($lockedServer->last_billed)->addMonths(6), + 'quarterly' => Carbon::parse($lockedServer->last_billed)->addMonths(3), + 'monthly' => Carbon::parse($lockedServer->last_billed)->addMonth(), + 'weekly' => Carbon::parse($lockedServer->last_billed)->addWeek(), + 'daily' => Carbon::parse($lockedServer->last_billed)->addDay(), + default => Carbon::parse($lockedServer->last_billed)->addHour(), + }; + + if (! $nextBillingDate->isPast()) { + return 'skip'; + } + + if ($lockedServer->canceled) { + return 'canceled'; + } + + if ($lockedUser->credits < $lockedProduct->price && $lockedProduct->price != 0) { + return 'insufficient_credits'; + } + + $this->line("{$lockedUser->name} Current credits: {$lockedUser->credits} Credits to be removed: {$lockedProduct->price}"); + $lockedUser->decrement('credits', $lockedProduct->price); + $lockedServer->update(['last_billed' => $nextBillingDate]); + + return 'charged'; + }); + + if ($chargeResult === 'insufficient_credits') { $this->suspendFunc($server, $user); } + } catch (Exception $exception) { + $this->error($exception->getMessage()); } } return $this->notifyUsers(); }); + + return Command::SUCCESS; } public function suspendFunc($server, $user) diff --git a/app/Console/Commands/CleanupOpenPayments.php b/app/Console/Commands/CleanupOpenPayments.php index 6fd96a7a1..3d6b8f7f0 100644 --- a/app/Console/Commands/CleanupOpenPayments.php +++ b/app/Console/Commands/CleanupOpenPayments.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Enums\PaymentStatus; use App\Models\Payment; use Illuminate\Console\Command; @@ -28,15 +29,17 @@ class CleanupOpenPayments extends Command */ public function handle() { - // delete all payments that have state "open" and are older than 1 hour + // Stale open payments are marked canceled so late provider callbacks can still be reconciled. try { - Payment::where('status', 'open')->where('updated_at', '<', now()->subHour())->delete(); + Payment::where('status', PaymentStatus::OPEN->value) + ->where('updated_at', '<', now()->subHour()) + ->update(['status' => PaymentStatus::CANCELED->value]); } catch (\Exception $e) { $this->error('Could not delete payments: ' . $e->getMessage()); return 1; } - $this->info('Successfully deleted all open payments'); + $this->info('Successfully marked stale open payments as canceled'); return Command::SUCCESS; } } diff --git a/app/Console/Commands/DisableRecaptcha.php b/app/Console/Commands/DisableRecaptcha.php index 1d6c981a7..895fcff8a 100644 --- a/app/Console/Commands/DisableRecaptcha.php +++ b/app/Console/Commands/DisableRecaptcha.php @@ -12,12 +12,11 @@ class DisableRecaptcha extends Command protected $signature = 'cp:recaptcha:toggle'; protected $description = 'Toggle Recaptcha version between null, v2, and v3 and Cloudflare turnstile.'; - protected GeneralSettings $settings; + protected ?GeneralSettings $settings = null; - public function __construct(GeneralSettings $settings) + public function __construct() { parent::__construct(); - $this->settings = $settings; } @@ -35,12 +34,13 @@ protected function getNextVersion(?string $current): ?string public function handle(): int { try { - $current = $this->settings->recaptcha_version; + $settings = $this->settings(); + $current = $settings->recaptcha_version; $next = $this->getNextVersion($current); - $this->settings->recaptcha_version = $next; - $this->settings->save(); + $settings->recaptcha_version = $next; + $settings->save(); $this->info("Recaptcha version is now: " . ($next ?? 'disabled') . ". Run again to set it to " . ($this->getNextVersion($next) ?? 'disabled') . "."); } catch (Exception $e) { @@ -50,4 +50,9 @@ public function handle(): int return Command::SUCCESS; } + + private function settings(): GeneralSettings + { + return $this->settings ??= app(GeneralSettings::class); + } } diff --git a/app/Console/Commands/GetGithubVersion.php b/app/Console/Commands/GetGithubVersion.php index 99ee7e5b9..cb6b08a91 100644 --- a/app/Console/Commands/GetGithubVersion.php +++ b/app/Console/Commands/GetGithubVersion.php @@ -32,7 +32,20 @@ class GetGithubVersion extends Command public function handle() { try{ - $latestVersion = Http::get('https://api.github.com/repos/ctrlpanel-gg/panel/tags')->json()[0]['name']; + $response = Http::timeout(10) + ->connectTimeout(5) + ->get('https://api.github.com/repos/ctrlpanel-gg/panel/tags'); + + if ($response->failed()) { + throw new Exception('Failed to fetch tags from GitHub.'); + } + + $latestVersion = data_get($response->json(), '0.name'); + + if (! is_string($latestVersion) || blank($latestVersion)) { + throw new Exception('GitHub response did not include a valid tag name.'); + } + Storage::disk('local')->put('latestVersion', $latestVersion); } catch (Exception $e) { Storage::disk('local')->put('latestVersion', "unknown"); diff --git a/app/Console/Commands/ImportUsersFromPteroCommand.php b/app/Console/Commands/ImportUsersFromPteroCommand.php index 8893693ba..5ed7152f1 100644 --- a/app/Console/Commands/ImportUsersFromPteroCommand.php +++ b/app/Console/Commands/ImportUsersFromPteroCommand.php @@ -3,8 +3,13 @@ namespace App\Console\Commands; use App\Models\User; +use JsonException; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Validator; +use Spatie\Permission\Models\Role; class ImportUsersFromPteroCommand extends Command { @@ -25,7 +30,7 @@ class ImportUsersFromPteroCommand extends Command * * @var string */ - protected $description = 'Command description'; + protected $description = 'Import users from a Pterodactyl export payload'; /** * Create a new command instance. @@ -40,7 +45,7 @@ public function __construct() /** * Execute the console command. * - * @return bool + * @return int */ public function handle() { @@ -49,55 +54,55 @@ public function handle() if (! Storage::disk('local')->exists('users.json')) { $this->error('[ERROR] '.storage_path('app').'/'.$this->importFileName.' is missing'); - return false; + return Command::FAILURE; } - //check if json file is valid - $json = json_decode(Storage::disk('local')->get('users.json')); - if (! array_key_exists(2, $json)) { - $this->error('[ERROR] Invalid json file'); + try { + $json = json_decode(Storage::disk('local')->get('users.json'), false, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + $this->error('[ERROR] Invalid json file: ' . $exception->getMessage()); - return false; + return Command::FAILURE; } - if (! $json[2]->data) { + + $users = $this->extractUsersFromPayload($json); + if ($users === []) { $this->error('[ERROR] Invalid json file / No users found!'); - return false; + return Command::FAILURE; } //ask questions :) $initial_credits = $this->option('initial_credits') ?? $this->ask('Please specify the amount of starting credits users should get. '); $initial_server_limit = $this->option('initial_server_limit') ?? $this->ask('Please specify the initial server limit users should get.'); - $confirm = strtolower($this->option('confirm') ?? $this->ask('[y/n] Are you sure you want to remove all existing users from the database continue importing?')); + $confirm = strtolower($this->option('confirm') ?? $this->ask('[y/n] Are you sure you want to import users from the JSON file?')); //cancel if ($confirm !== 'y') { $this->error('[ERROR] Stopped import script!'); - return false; + return Command::INVALID; } - //import users - $this->deleteCurrentUserBase(); - $this->importUsingJsonFile($json, $initial_credits, $initial_server_limit); + $validationErrors = $this->validateImportPayload($users, $initial_credits, $initial_server_limit); + if ($validationErrors !== []) { + foreach ($validationErrors as $error) { + $this->error('[ERROR] ' . $error); + } - return true; - } - - /** - * @return void - */ - private function deleteCurrentUserBase() - { - $currentUserCount = User::count(); - if ($currentUserCount == 0) { - return; + return Command::FAILURE; } - $this->line("Deleting ({$currentUserCount}) users.."); - foreach (User::all() as $user) { - $user->delete(); + if (User::query()->exists()) { + $this->error('[ERROR] Import aborted. The database already contains users, and this command no longer deletes existing accounts automatically.'); + + return Command::FAILURE; } + + //import users + $this->importUsingJsonFile($users, $initial_credits, $initial_server_limit); + + return Command::SUCCESS; } /** @@ -106,26 +111,92 @@ private function deleteCurrentUserBase() * @param $initial_server_limit * @return void */ - private function importUsingJsonFile($json, $initial_credits, $initial_server_limit) + private function importUsingJsonFile(array $users, $initial_credits, $initial_server_limit) { - $this->withProgressBar($json[2]->data, function ($user) use ($initial_server_limit, $initial_credits) { - $role = $user->root_admin == '0' ? 'member' : 'admin'; - - User::create([ - 'pterodactyl_id' => $user->id, - 'name' => $user->name_first, - 'email' => $user->email, - 'password' => $user->password, - 'role' => $role, - 'credits' => $initial_credits, - 'server_limit' => $initial_server_limit, - 'created_at' => $user->created_at, - 'updated_at' => $user->updated_at, - ]); + DB::transaction(function () use ($users, $initial_credits, $initial_server_limit) { + $this->withProgressBar($users, function ($user) use ($initial_server_limit, $initial_credits) { + $importedUser = User::create([ + 'pterodactyl_id' => $user->id, + 'name' => $user->name_first, + 'email' => $user->email, + 'password' => $user->password, + 'credits' => $initial_credits, + 'server_limit' => $initial_server_limit, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ]); + + $roleName = $user->root_admin == '0' ? 'User' : 'Admin'; + $role = Role::query()->where('name', $roleName)->first(); + if ($role) { + $importedUser->syncRoles($role); + } + }); }); $this->newLine(); $this->line('Done importing, you can now login using your pterodactyl credentials.'); $this->newLine(); } + + private function validateImportPayload(array $users, $initial_credits, $initial_server_limit): array + { + $errors = []; + $seenEmails = []; + $seenPterodactylIds = []; + + foreach ($users as $index => $user) { + $validator = Validator::make((array) $user, [ + 'id' => 'required|integer', + 'email' => 'required|email', + 'name_first' => 'required|string|min:1|max:255', + 'password' => 'required|string|min:1', + 'root_admin' => 'required', + ]); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $errors[] = "Row {$index}: {$error}"; + } + } + + if (in_array($user->email, $seenEmails, true)) { + $errors[] = "Row {$index}: duplicate email {$user->email}"; + } + + if (in_array($user->id, $seenPterodactylIds, true)) { + $errors[] = "Row {$index}: duplicate pterodactyl id {$user->id}"; + } + + if (password_get_info($user->password)['algo'] === null) { + $errors[] = "Row {$index}: password is not a supported password hash."; + } + + $seenEmails[] = $user->email; + $seenPterodactylIds[] = $user->id; + } + + if (! is_numeric($initial_credits) || ! is_numeric($initial_server_limit)) { + $errors[] = 'Initial credits and server limit must be numeric.'; + } + + return $errors; + } + + private function extractUsersFromPayload(mixed $payload): array + { + if (is_array($payload) && isset($payload[2]->data) && is_array($payload[2]->data)) { + return $payload[2]->data; + } + + if (is_object($payload) && isset($payload->data) && is_array($payload->data)) { + return $payload->data; + } + + if (is_object($payload) && isset($payload->users) && is_array($payload->users)) { + return $payload->users; + } + + return []; + } } diff --git a/app/Console/Commands/MakeUserCommand.php b/app/Console/Commands/MakeUserCommand.php index 42d045e5e..609f34bcd 100644 --- a/app/Console/Commands/MakeUserCommand.php +++ b/app/Console/Commands/MakeUserCommand.php @@ -2,15 +2,15 @@ namespace App\Console\Commands; +use App\Constants\Roles; use App\Classes\PterodactylClient; use App\Models\User; -use App\Settings\GeneralSettings; -use App\Settings\PterodactylSettings; use App\Settings\UserSettings; use App\Traits\Referral; use Illuminate\Console\Command; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Spatie\Permission\Models\Role; class MakeUserCommand extends Command { @@ -48,11 +48,11 @@ public function __construct() * * @return int */ - public function handle(PterodactylSettings $ptero_settings, UserSettings $user_settings) + public function handle(PterodactylClient $pterodactyl, UserSettings $user_settings) { - $this->pterodactyl = new PterodactylClient($ptero_settings); + $this->pterodactyl = $pterodactyl; $ptero_id = $this->option('ptero_id') ?? $this->ask('Please specify your Pterodactyl ID.'); - $password = $this->secret('password') ?? $this->ask('Please specify your password.'); + $password = $this->option('password') ?? $this->secret('password'); // Validate user input $validator = Validator::make([ @@ -66,7 +66,7 @@ public function handle(PterodactylSettings $ptero_settings, UserSettings $user_s if ($validator->fails()) { $this->error($validator->errors()->first()); - return 0; + return Command::FAILURE; } //TODO: Do something with response (check for status code and give hints based upon that) @@ -83,7 +83,15 @@ public function handle(PterodactylSettings $ptero_settings, UserSettings $user_s $this->error("detail: {$response['errors'][0]['detail']}"); } - return 0; + return Command::FAILURE; + } + + foreach (['id', 'email', 'first_name'] as $requiredKey) { + if (! array_key_exists($requiredKey, $response) || blank($response[$requiredKey])) { + $this->error("Invalid response from Pterodactyl: missing {$requiredKey}."); + + return Command::FAILURE; + } } $exists = User::where('email', $response['email']) @@ -92,7 +100,7 @@ public function handle(PterodactylSettings $ptero_settings, UserSettings $user_s if ($exists) { $this->error('A user with this email or Pterodactyl ID already exists.'); - return 0; + return Command::FAILURE; } $user = User::create([ @@ -113,8 +121,19 @@ public function handle(PterodactylSettings $ptero_settings, UserSettings $user_s ['Referral code', $user->referral_code], ]); - $user->syncRoles(1); + $adminRole = Role::query() + ->where('id', Roles::ADMIN_ROLE_ID) + ->orWhere('name', 'Admin') + ->first(); + + if (! $adminRole) { + $this->error('Admin role not found. Please seed roles and permissions first.'); + + return Command::FAILURE; + } + + $user->syncRoles($adminRole); - return 1; + return Command::SUCCESS; } } diff --git a/app/Console/Commands/NotifyServerSuspension.php b/app/Console/Commands/NotifyServerSuspension.php index 3cb9dc439..78822e47f 100644 --- a/app/Console/Commands/NotifyServerSuspension.php +++ b/app/Console/Commands/NotifyServerSuspension.php @@ -64,7 +64,7 @@ public function handle() $this->info("Completed! Checked: {$serversChecked} servers, Sent warnings for: {$serversNotified} servers"); - return 0; + return Command::SUCCESS; } private function getSuspensionDate(Server $server, string $billingPeriod): Carbon diff --git a/app/Console/Commands/notify.php b/app/Console/Commands/notify.php index 8b2c6ca1e..8f54c03eb 100644 --- a/app/Console/Commands/notify.php +++ b/app/Console/Commands/notify.php @@ -41,8 +41,16 @@ public function __construct() */ public function handle() { - User::findOrFail($this->argument('id'))->notify(new ServerCreationError(Server::all()[0])); + $server = Server::query()->first(); + if (! $server) { + $this->error('No server found to attach to the test notification.'); + return self::FAILURE; + } - return 'message send'; + User::findOrFail($this->argument('id'))->notify(new ServerCreationError($server)); + + $this->info('Message sent.'); + + return self::SUCCESS; } } diff --git a/app/Console/Commands/update.php b/app/Console/Commands/update.php index deb12c863..b8f527e03 100644 --- a/app/Console/Commands/update.php +++ b/app/Console/Commands/update.php @@ -6,6 +6,8 @@ use Illuminate\Console\Command; use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Process\Process; +use InvalidArgumentException; +use RuntimeException; class update extends Command { @@ -44,73 +46,69 @@ public function handle() { $this->output->warning('This command does just pull the newest changes from the github repo. Verify the github repo before running this'); - if (version_compare(PHP_VERSION, '8.0.0') < 0) { - $this->error('Cannot execute self-upgrade process. The minimum required PHP version required is 8.0.0, you have ['.PHP_VERSION.'].'); + if (version_compare(PHP_VERSION, '8.2.0') < 0) { + $this->error('Cannot execute self-upgrade process. The minimum required PHP version required is 8.2.0, you have ['.PHP_VERSION.'].'); + return Command::FAILURE; } $user = 'www-data'; $group = 'www-data'; - if ($this->input->isInteractive()) { - if (is_null($this->option('user'))) { - $userDetails = posix_getpwuid(fileowner('public')); - $user = $userDetails['name'] ?? 'www-data'; - - if (! $this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) { - $user = $this->anticipate( - 'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".', - [ - 'www-data', - 'nginx', - 'apache', - ] - ); - } + if (is_null($this->option('user'))) { + $userDetails = posix_getpwuid(fileowner('public')); + $user = $userDetails['name'] ?? 'www-data'; + + if ($this->input->isInteractive() && ! $this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) { + $user = $this->anticipate( + 'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".', + ['www-data', 'nginx', 'apache'] + ); } + } - if (is_null($this->option('group'))) { - $groupDetails = posix_getgrgid(filegroup('public')); - $group = $groupDetails['name'] ?? 'www-data'; - - if (! $this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) { - $group = $this->anticipate( - 'Please enter the name of the group running your webserver process. Normally this is the same as your user.', - [ - 'www-data', - 'nginx', - 'apache', - ] - ); - } + if (is_null($this->option('group'))) { + $groupDetails = posix_getgrgid(filegroup('public')); + $group = $groupDetails['name'] ?? 'www-data'; + + if ($this->input->isInteractive() && ! $this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) { + $group = $this->anticipate( + 'Please enter the name of the group running your webserver process. Normally this is the same as your user.', + ['www-data', 'nginx', 'apache'] + ); } + } - ini_set('output_buffering', 0); + $user = $this->validateOwnershipIdentifier($this->option('user') ?? $user, 'user'); + $group = $this->validateOwnershipIdentifier($this->option('group') ?? $group, 'group'); - if (! $this->confirm('Are you sure you want to run the upgrade process for your Dashboard?')) { - return false; - } + ini_set('output_buffering', 0); + + if ($this->input->isInteractive() && ! $this->confirm('Are you sure you want to run the upgrade process for your Dashboard?')) { + return Command::INVALID; + } - $bar = $this->output->createProgressBar(9); - $bar->start(); + $bar = $this->output->createProgressBar(8); + $bar->start(); + $maintenanceEnabled = false; + + try { $this->withProgress($bar, function () { $this->line('$upgrader> git pull'); - $process = Process::fromShellCommandline('git pull'); - $process->run(function ($type, $buffer) { - $this->{$type === Process::ERR ? 'error' : 'line'}($buffer); - }); + $this->runProcessOrFail(Process::fromShellCommandline('git pull')); }); - $this->withProgress($bar, function () { + $this->withProgress($bar, function () use (&$maintenanceEnabled) { $this->line('$upgrader> php artisan down'); - $this->call('down'); + if ($this->call('down') !== Command::SUCCESS) { + throw new RuntimeException('Failed to enable maintenance mode.'); + } + + $maintenanceEnabled = true; }); $this->withProgress($bar, function () { $this->line('$upgrader> chmod -R 755 storage bootstrap/cache'); - $process = new Process(['chmod', '-R', '755', 'storage', 'bootstrap/cache']); - $process->run(function ($type, $buffer) { - $this->{$type === Process::ERR ? 'error' : 'line'}($buffer); - }); + $this->runProcessOrFail(new Process(['chmod', '-R', '755', 'storage', 'bootstrap/cache'])); }); $this->withProgress($bar, function () { @@ -123,43 +121,55 @@ public function handle() $this->line('$upgrader> '.implode(' ', $command)); $process = new Process($command); $process->setTimeout(10 * 60); - $process->run(function ($type, $buffer) { - $this->line($buffer); - }); + $this->runProcessOrFail($process); }); $this->withProgress($bar, function () { $this->line('$upgrader> php artisan view:clear'); - $this->call('view:clear'); + if ($this->call('view:clear') !== Command::SUCCESS) { + throw new RuntimeException('Failed to clear compiled views.'); + } }); $this->withProgress($bar, function () { $this->line('$upgrader> php artisan config:clear'); - $this->call('config:clear'); + if ($this->call('config:clear') !== Command::SUCCESS) { + throw new RuntimeException('Failed to clear config cache.'); + } }); $this->withProgress($bar, function () { $this->line('$upgrader> php artisan migrate --force'); - $this->call('migrate', ['--force' => '']); + if ($this->call('migrate', ['--force' => '']) !== Command::SUCCESS) { + throw new RuntimeException('Database migrations failed.'); + } }); $this->withProgress($bar, function () use ($user, $group) { - $this->line("\$upgrader> chown -R {$user}:{$group} *"); - $process = Process::fromShellCommandline("chown -R {$user}:{$group} *", $this->getLaravel()->basePath()); + $this->line("\$upgrader> chown -R {$user}:{$group} storage bootstrap/cache"); + $process = new Process(['chown', '-R', "{$user}:{$group}", 'storage', 'bootstrap/cache'], $this->getLaravel()->basePath()); $process->setTimeout(10 * 60); - $process->run(function ($type, $buffer) { - $this->{$type === Process::ERR ? 'error' : 'line'}($buffer); - }); + $this->runProcessOrFail($process); }); + } catch (\Throwable $exception) { + $this->error($exception->getMessage()); - $this->withProgress($bar, function () { - $this->line('$upgrader> php artisan up'); + if ($maintenanceEnabled) { $this->call('up'); - }); + } + + return Command::FAILURE; + } - $this->newLine(); - $this->info('Finished running upgrade.'); + if ($maintenanceEnabled) { + $this->line('$upgrader> php artisan up'); + $this->call('up'); } + + $this->newLine(); + $this->info('Finished running upgrade.'); + + return Command::SUCCESS; } protected function withProgress(ProgressBar $bar, Closure $callback) @@ -169,4 +179,24 @@ protected function withProgress(ProgressBar $bar, Closure $callback) $bar->advance(); $bar->display(); } + + private function runProcessOrFail(Process $process): void + { + $process->run(function ($type, $buffer) { + $this->{$type === Process::ERR ? 'error' : 'line'}($buffer); + }); + + if (! $process->isSuccessful()) { + throw new RuntimeException($process->getErrorOutput() ?: $process->getOutput() ?: 'Command execution failed.'); + } + } + + private function validateOwnershipIdentifier(string $value, string $label): string + { + if (! preg_match('/^[A-Za-z0-9._-]+$/', $value)) { + throw new InvalidArgumentException("Invalid {$label} value supplied."); + } + + return $value; + } } diff --git a/app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php b/app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php index f9a7d24fc..a213387c0 100644 --- a/app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php +++ b/app/Extensions/PaymentGateways/MercadoPago/MercadoPagoExtension.php @@ -9,16 +9,16 @@ use App\Models\Payment; use App\Models\ShopProduct; use App\Models\User; +use App\Notifications\ConfirmPaymentNotification; use Exception; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Http; -use App\Notifications\ConfirmPaymentNotification; -use App\Extensions\PaymentGateways\MercadoPago\MercadoPagoSettings; -use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\URL; /** * Summary of MercadoPagoExtension @@ -57,11 +57,11 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct $response = Http::withHeaders([ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $settings->access_token, - ])->post($url, [ + ])->timeout(30)->connectTimeout(10)->post($url, [ 'back_urls' => [ - 'success' => route('payment.MercadoPagoSuccess'), + 'success' => URL::temporarySignedRoute('payment.MercadoPagoSuccess', now()->addDay(), ['payment' => $payment->id]), 'failure' => route('payment.Cancel'), - 'pending' => route('payment.MercadoPagoSuccess'), + 'pending' => URL::temporarySignedRoute('payment.MercadoPagoPending', now()->addDay(), ['payment' => $payment->id]), ], 'auto_return' => 'approved', 'notification_url' => route('payment.MercadoPagoWebhook'), @@ -70,7 +70,7 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct ], 'items' => [ [ - 'title' => "Order #{$payment->id} - " . $shopProduct->name, + 'title' => "Order #{$payment->id} - " . $shopProduct->display, 'quantity' => 1, 'unit_price' => $totalPrice, 'currency_id' => $shopProduct->currency_code, @@ -87,7 +87,10 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct if ($response->successful()) { return $response->json()['init_point']; } else { - Log::error('MercadoPago Payment: ' . $response->body()); + Log::error('MercadoPago payment preference creation failed.', [ + 'status' => $response->status(), + 'error' => $response->json('message') ?: $response->json('error') ?: 'Unknown MercadoPago error', + ]); throw new Exception('Payment failed'); } } catch (Exception $ex) { @@ -96,87 +99,47 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct } } - static function Success(Request $request): RedirectResponse + public static function Success(Request $request): RedirectResponse { - $payment = Payment::findOrFail($request->input('external_reference')); - - // In some cases, the webhook is received even before the success route. - if ($payment->status === PaymentStatus::PAID) { - return Redirect::route('home')->with('success', 'Your payment has already been processed!')->send(); - } - - $payment->status = PaymentStatus::PROCESSING; - $payment->save(); + return self::handleCheckoutReturn($request, false); + } - return Redirect::route('home')->with('success', 'Your payment is being processed!')->send(); + public static function Pending(Request $request): RedirectResponse + { + return self::handleCheckoutReturn($request, true); } - static function Webhook(Request $request): JsonResponse + public static function Webhook(Request $request): JsonResponse { $topic = $request->input('topic'); $action = $request->input('action'); - /** - * Mercado Pago sends several requests for information in the webhook, - * but most are for other types of API, and that is why it is filtered here. - */ - if ($topic && ($topic === 'merchant_order' || $topic === 'payment')) { - return response()->json(['success' => true]); - } - try { - if($action) { - $notification = $request['data']['id']; - - // Filter the API for payments - if (!$notification || !$action) return response()->json(['success' => false], 400); - // Mercado pago test api, for testing webhook request - if ($notification == '123456') return response()->json(['success' => true], 200); - - /** - * Check action have payment.*, - * what is expected for this type of api - */ - if (str_contains($action, 'payment')) { - $url = "https://api.mercadopago.com/v1/payments/" . $notification; - $settings = new MercadoPagoSettings(); - $response = Http::withHeaders([ - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer ' . $settings->access_token, - ])->get($url); - if ($response->successful()) { - $mercado = $response->json(); - $status = $mercado['status']; - $payment = Payment::findOrFail($mercado['metadata']['crtl_panel_payment_id']); - $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); - if ($status == "approved") { - // Avoid double addition of credits, whether due to double requests from the paid market, or a malicious user - if ($payment->status !== PaymentStatus::PAID) { - $user = User::findOrFail($payment->user_id); - $payment->status = PaymentStatus::PAID; - $payment->save(); - $user->notify(new ConfirmPaymentNotification($payment)); - event(new PaymentEvent($user, $payment, $shopProduct)); - event(new UserUpdateCreditsEvent($user)); - } - } else { - if ($status == "cancelled") { - $user = User::findOrFail($payment->user_id); - $payment->status = PaymentStatus::CANCELED; - } else { - $user = User::findOrFail($payment->user_id); - $payment->status = PaymentStatus::PROCESSING; - } - $payment->save(); - event(new PaymentEvent($user, $payment, $shopProduct)); - } - return response()->json(['success' => true]); - } else { - return response()->json(['success' => false]); - } - } else { - return response()->json(['success' => false]); + $paymentIds = []; + $notificationId = (string) data_get($request->all(), 'data.id', ''); + + if ($topic === 'merchant_order' && $notificationId !== '') { + $paymentIds = self::fetchMerchantOrderPaymentIds($notificationId); + } elseif ($topic === 'payment' && $notificationId !== '') { + $paymentIds = [$notificationId]; + } elseif ($action && str_contains($action, 'payment') && $notificationId !== '') { + $paymentIds = [$notificationId]; + } + + if ($paymentIds === []) { + return response()->json(['success' => false], 400); + } + + foreach (array_unique($paymentIds) as $paymentId) { + $mercado = self::fetchMercadoPagoPayment((string) $paymentId); + $payment = Payment::findOrFail($mercado['metadata']['crtl_panel_payment_id']); + if ($payment->payment_method !== 'MercadoPago') { + abort(403); } + + $user = User::findOrFail($payment->user_id); + $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + self::syncMercadoPagoPaymentState($payment, $mercado, $user, $shopProduct); } } catch (Exception $ex) { Log::error('MercadoPago Webhook(IPN) Payment: ' . $ex->getMessage()); @@ -184,4 +147,193 @@ static function Webhook(Request $request): JsonResponse } return response()->json(['success' => true]); } + + private static function assertMercadoPagoPaymentMatches(Payment $payment, array $mercadoPayment): void + { + $expectedAmount = number_format($payment->total_price / 1000, 2, '.', ''); + $actualAmount = number_format((float) ($mercadoPayment['transaction_amount'] ?? 0), 2, '.', ''); + $expectedCurrency = strtoupper($payment->currency_code); + $actualCurrency = strtoupper((string) ($mercadoPayment['currency_id'] ?? '')); + $metadataPaymentId = (string) ($mercadoPayment['metadata']['crtl_panel_payment_id'] ?? ''); + $externalReference = (string) ($mercadoPayment['external_reference'] ?? ''); + $metadataUserId = (string) ($mercadoPayment['metadata']['user_id'] ?? ''); + + if ($metadataPaymentId !== (string) $payment->id + || ($externalReference !== '' && $externalReference !== (string) $payment->id) + || ($metadataUserId !== '' && $metadataUserId !== (string) $payment->user_id) + || $actualCurrency !== $expectedCurrency + || $actualAmount !== $expectedAmount) { + Log::critical('MercadoPago payment amount mismatch detected', [ + 'payment_id' => $payment->id, + 'expected_amount' => $expectedAmount, + 'received_amount' => $actualAmount, + 'expected_currency' => $expectedCurrency, + 'received_currency' => $actualCurrency, + 'expected_user_id' => $payment->user_id, + 'received_user_id' => $metadataUserId, + ]); + + throw new Exception('MercadoPago payment verification failed.'); + } + } + + private static function handleCheckoutReturn(Request $request, bool $pendingFlow): RedirectResponse + { + if (! $request->hasValidSignatureWhileIgnoring([ + 'collection_id', + 'collection_status', + 'external_reference', + 'merchant_order_id', + 'payment_id', + 'preference_id', + 'site_id', + 'status', + ])) { + abort(403); + } + + $payment = Payment::findOrFail($request->input('payment')); + if ($payment->payment_method !== 'MercadoPago') { + abort(403); + } + + if ($payment->status === PaymentStatus::PAID) { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment has already been processed!'); + } + + $paymentOwner = User::findOrFail($payment->user_id); + $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + $remotePaymentId = (string) ($request->input('payment_id') ?: $request->input('collection_id') ?: ''); + + try { + if ($remotePaymentId !== '') { + $mercado = self::fetchMercadoPagoPayment($remotePaymentId); + $state = self::syncMercadoPagoPaymentState($payment, $mercado, $paymentOwner, $shopProduct); + + return match ($state) { + 'paid' => Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'), + 'canceled' => Redirect::route(self::getCallbackRedirectRoute())->with('info', __('Your payment has been canceled!')), + default => Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment is being processed!'), + }; + } + } catch (Exception $ex) { + Log::error('MercadoPago success callback failed.', [ + 'payment_id' => $payment->id, + 'message' => $ex->getMessage(), + ]); + } + + if ($pendingFlow || in_array((string) $request->input('status'), ['pending', 'in_process'], true)) { + Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update(['status' => PaymentStatus::PROCESSING->value]); + + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment is being processed!'); + } + + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your Mercado Pago payment. If you were charged, please contact support.') + ); + } + + private static function fetchMercadoPagoPayment(string $notification): array + { + $settings = new MercadoPagoSettings(); + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $settings->access_token, + ])->timeout(30)->connectTimeout(10)->get("https://api.mercadopago.com/v1/payments/{$notification}"); + + if (! $response->successful()) { + Log::error('MercadoPago payment lookup failed.', [ + 'status' => $response->status(), + 'error' => $response->json('message') ?: $response->json('error') ?: 'Unknown MercadoPago error', + ]); + throw new Exception('MercadoPago payment lookup failed.'); + } + + return $response->json(); + } + + private static function fetchMerchantOrderPaymentIds(string $merchantOrderId): array + { + $settings = new MercadoPagoSettings(); + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $settings->access_token, + ])->timeout(30)->connectTimeout(10)->get("https://api.mercadopago.com/merchant_orders/{$merchantOrderId}"); + + if (! $response->successful()) { + Log::error('MercadoPago merchant order lookup failed.', [ + 'status' => $response->status(), + 'error' => $response->json('message') ?: $response->json('error') ?: 'Unknown MercadoPago error', + ]); + throw new Exception('MercadoPago merchant order lookup failed.'); + } + + return collect($response->json('payments', [])) + ->pluck('id') + ->filter() + ->map(fn ($id) => (string) $id) + ->values() + ->all(); + } + + private static function syncMercadoPagoPaymentState(Payment $payment, array $mercado, User $user, ShopProduct $shopProduct): string + { + $status = (string) ($mercado['status'] ?? ''); + self::assertMercadoPagoPaymentMatches($payment, $mercado); + + if ($status === 'approved') { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($mercado['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::PAID->value, + ]); + + if ($updated > 0) { + $payment = $payment->fresh(); + $user->notify(new ConfirmPaymentNotification($payment)); + event(new PaymentEvent($user, $payment, $shopProduct)); + event(new UserUpdateCreditsEvent($user)); + } + + return 'paid'; + } + + if (in_array($status, ['cancelled', 'rejected', 'refunded', 'charged_back'], true)) { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($mercado['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::CANCELED->value, + ]); + + if ($updated > 0) { + event(new PaymentEvent($user, $payment->fresh(), $shopProduct)); + } + + return 'canceled'; + } + + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($mercado['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::PROCESSING->value, + ]); + + if ($updated > 0) { + event(new PaymentEvent($user, $payment->fresh(), $shopProduct)); + } + + return 'processing'; + } + + private static function getCallbackRedirectRoute(): string + { + return Auth::check() ? 'home' : 'login'; + } } diff --git a/app/Extensions/PaymentGateways/MercadoPago/routes.php b/app/Extensions/PaymentGateways/MercadoPago/routes.php index e826d6da5..39a75af51 100644 --- a/app/Extensions/PaymentGateways/MercadoPago/routes.php +++ b/app/Extensions/PaymentGateways/MercadoPago/routes.php @@ -3,16 +3,23 @@ use Illuminate\Support\Facades\Route; use App\Extensions\PaymentGateways\MercadoPago\MercadoPagoExtension; -Route::middleware(['web', 'auth'])->group(function () { +Route::middleware(['web'])->group(function () { Route::get( 'payment/MercadoPagoSuccess', function () { - MercadoPagoExtension::Success(request()); + return MercadoPagoExtension::Success(request()); } )->name('payment.MercadoPagoSuccess'); + + Route::get( + 'payment/MercadoPagoPending', + function () { + return MercadoPagoExtension::Pending(request()); + } + )->name('payment.MercadoPagoPending'); }); Route::post('payment/MercadoPagoWebhook', function () { - MercadoPagoExtension::Webhook(request()); + return MercadoPagoExtension::Webhook(request()); })->name('payment.MercadoPagoWebhook'); diff --git a/app/Extensions/PaymentGateways/Mollie/MollieExtension.php b/app/Extensions/PaymentGateways/Mollie/MollieExtension.php index 474e2806c..02a12b227 100644 --- a/app/Extensions/PaymentGateways/Mollie/MollieExtension.php +++ b/app/Extensions/PaymentGateways/Mollie/MollieExtension.php @@ -14,9 +14,11 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Redirect; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\URL; /** * Summary of PayPalExtension @@ -49,13 +51,13 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct $response = Http::withHeaders([ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $settings->api_key, - ])->post($url, [ + ])->timeout(30)->connectTimeout(10)->post($url, [ 'amount' => [ 'currency' => $shopProduct->currency_code, 'value' => $totalPrice, ], - 'description' => "Order #{$payment->id} - " . $shopProduct->name, - 'redirectUrl' => route('payment.MollieSuccess', ['payment_id' => $payment->id]), + 'description' => "Order #{$payment->id} - " . $shopProduct->display, + 'redirectUrl' => URL::temporarySignedRoute('payment.MollieSuccess', now()->addDay(), ['payment' => $payment->id]), 'cancelUrl' => route('payment.Cancel'), 'webhookUrl' => route('payment.MollieWebhook'), 'metadata' => [ @@ -64,10 +66,17 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct ]); if ($response->status() != 201) { - Log::error('Mollie Payment: ' . $response->body()); + Log::error('Mollie payment creation failed.', [ + 'status' => $response->status(), + 'error' => $response->json('title') ?: $response->json('detail') ?: 'Unknown Mollie error', + ]); throw new Exception('Payment failed'); } + $payment->forceFill([ + 'payment_id' => (string) $response->json('id', $payment->payment_id), + ])->save(); + return $response->json()['_links']['checkout']['href']; } catch (Exception $ex) { Log::error('Mollie Payment: ' . $ex->getMessage()); @@ -77,44 +86,61 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct static function success(Request $request): RedirectResponse { - $payment = Payment::findOrFail($request->input('payment_id')); + if (! $request->hasValidSignature()) { + abort(403); + } + + $payment = Payment::findOrFail($request->input('payment')); + if ($payment->payment_method !== 'Mollie') { + abort(403); + } if ($payment->status === PaymentStatus::PAID) { - return Redirect::route('home')->with('success', 'Your payment has already been processed!')->send(); + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment has already been processed!'); } - $payment->status = PaymentStatus::PROCESSING; - $payment->save(); + $paymentOwner = User::findOrFail($payment->user_id); + $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + + try { + if (! $payment->payment_id) { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment is being processed'); + } + + $remotePayment = self::fetchMolliePayment((string) $payment->payment_id); + $state = self::syncMolliePaymentState($payment, $remotePayment, $paymentOwner, $shopProduct); + + return match ($state) { + 'paid' => Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'), + 'canceled' => Redirect::route(self::getCallbackRedirectRoute())->with('info', __('Your payment has been canceled!')), + default => Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment is being processed'), + }; + } catch (Exception $ex) { + Log::error('Mollie success callback failed.', [ + 'payment_id' => $payment->id, + 'message' => $ex->getMessage(), + ]); - return Redirect::route('home')->with('success', 'Your payment is being processed')->send(); + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your Mollie payment. If you were charged, please contact support.') + ); + } } static function webhook(Request $request): JsonResponse { - $url = 'https://api.mollie.com/v2/payments/' . $request->id; - $settings = new MollieSettings(); - try { - $response = Http::withHeaders([ - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer ' . $settings->api_key, - ])->get($url); - if ($response->status() != 200) { - Log::error('Mollie Payment Webhook: ' . $response->json()['title']); - return response()->json(['success' => false]); + $remotePayment = self::fetchMolliePayment((string) $request->id); + $payment = Payment::findOrFail($remotePayment['metadata']['payment_id']); + if ($payment->payment_method !== 'Mollie') { + abort(403); } - $payment = Payment::findOrFail($response->json()['metadata']['payment_id']); $user = User::findOrFail($payment->user_id); $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); - if ($response->json()['status'] == 'paid') { - $payment->status = PaymentStatus::PAID; - $payment->save(); - $user->notify(new ConfirmPaymentNotification($payment)); - event(new PaymentEvent($user, $payment, $shopProduct)); - event(new UserUpdateCreditsEvent($user)); - } + self::syncMolliePaymentState($payment, $remotePayment, $user, $shopProduct); } catch (Exception $ex) { Log::error('Mollie Payment Webhook: ' . $ex->getMessage()); return response()->json(['success' => false]); @@ -123,4 +149,99 @@ static function webhook(Request $request): JsonResponse // return a 200 status code return response()->json(['success' => true]); } + + private static function assertMolliePaymentMatches(Payment $payment, array $remotePayment): void + { + $expectedAmount = number_format($payment->total_price / 1000, 2, '.', ''); + $actualAmount = number_format((float) ($remotePayment['amount']['value'] ?? 0), 2, '.', ''); + $expectedCurrency = strtoupper($payment->currency_code); + $actualCurrency = strtoupper((string) ($remotePayment['amount']['currency'] ?? '')); + + if (($remotePayment['metadata']['payment_id'] ?? null) !== $payment->id + || $actualCurrency !== $expectedCurrency + || $actualAmount !== $expectedAmount) { + Log::critical('Mollie payment amount mismatch detected', [ + 'payment_id' => $payment->id, + 'expected_amount' => $expectedAmount, + 'received_amount' => $actualAmount, + 'expected_currency' => $expectedCurrency, + 'received_currency' => $actualCurrency, + ]); + + throw new Exception('Mollie payment amount verification failed.'); + } + } + + private static function fetchMolliePayment(string $remotePaymentId): array + { + $settings = new MollieSettings(); + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $settings->api_key, + ])->timeout(30)->connectTimeout(10)->get('https://api.mollie.com/v2/payments/' . $remotePaymentId); + + if ($response->status() != 200) { + Log::error('Mollie payment lookup failed.', [ + 'status' => $response->status(), + 'error' => $response->json('title') ?: $response->json('detail') ?: 'Unknown Mollie error', + ]); + throw new Exception('Mollie payment lookup failed.'); + } + + return $response->json(); + } + + private static function syncMolliePaymentState(Payment $payment, array $remotePayment, User $user, ShopProduct $shopProduct): string + { + self::assertMolliePaymentMatches($payment, $remotePayment); + + $status = (string) ($remotePayment['status'] ?? ''); + + if ($status === 'paid') { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($remotePayment['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::PAID->value, + ]); + + if ($updated > 0) { + $payment = $payment->fresh(); + $user->notify(new ConfirmPaymentNotification($payment)); + event(new PaymentEvent($user, $payment, $shopProduct)); + event(new UserUpdateCreditsEvent($user)); + } + + return 'paid'; + } + + if (in_array($status, ['pending', 'authorized', 'processing'], true)) { + Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($remotePayment['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::PROCESSING->value, + ]); + + return 'processing'; + } + + if (in_array($status, ['failed', 'expired', 'canceled'], true)) { + Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => (string) ($remotePayment['id'] ?? $payment->payment_id), + 'status' => PaymentStatus::CANCELED->value, + ]); + + return 'canceled'; + } + + return 'processing'; + } + + private static function getCallbackRedirectRoute(): string + { + return Auth::check() ? 'home' : 'login'; + } } diff --git a/app/Extensions/PaymentGateways/Mollie/routes.php b/app/Extensions/PaymentGateways/Mollie/routes.php index 6d07f533d..aaac4a9a5 100644 --- a/app/Extensions/PaymentGateways/Mollie/routes.php +++ b/app/Extensions/PaymentGateways/Mollie/routes.php @@ -3,16 +3,16 @@ use Illuminate\Support\Facades\Route; use App\Extensions\PaymentGateways\Mollie\MollieExtension; -Route::middleware(['web', 'auth'])->group(function () { +Route::middleware(['web'])->group(function () { Route::get( 'payment/MollieSuccess', function () { - MollieExtension::success(request()); + return MollieExtension::success(request()); } )->name('payment.MollieSuccess'); }); Route::post('payment/MollieWebhook', function () { - MollieExtension::webhook(request()); + return MollieExtension::webhook(request()); })->name('payment.MollieWebhook'); diff --git a/app/Extensions/PaymentGateways/PayPal/PayPalExtension.php b/app/Extensions/PaymentGateways/PayPal/PayPalExtension.php index 1c9e2e370..a28764b78 100644 --- a/app/Extensions/PaymentGateways/PayPal/PayPalExtension.php +++ b/app/Extensions/PaymentGateways/PayPal/PayPalExtension.php @@ -9,16 +9,13 @@ use App\Models\Payment; use App\Models\ShopProduct; use App\Models\User; +use App\Notifications\ConfirmPaymentNotification; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Redirect; use Illuminate\Support\Facades\Log; -use PayPalCheckoutSdk\Core\PayPalHttpClient; -use PayPalCheckoutSdk\Core\ProductionEnvironment; -use PayPalCheckoutSdk\Core\SandboxEnvironment; -use PayPalCheckoutSdk\Orders\OrdersCaptureRequest; -use PayPalCheckoutSdk\Orders\OrdersCreateRequest; -use PayPalHttp\HttpException; +use Illuminate\Support\Facades\URL; /** @@ -36,18 +33,13 @@ public static function getConfig(): array public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct, int $totalPrice): string { - // Converts from cents to decimal places. - $totalPrice = round($totalPrice / 1000,2); - - - - $request = new OrdersCreateRequest(); - $request->prefer('return=representation'); - $request->body = [ + $totalPrice = number_format($totalPrice / 1000, 2, '.', ''); + $payload = [ "intent" => "CAPTURE", "purchase_units" => [ [ - "reference_id" => uniqid(), + "reference_id" => (string) \Illuminate\Support\Str::ulid(), + "custom_id" => $payment->id, "description" => $shopProduct->display, "amount" => [ "value" => $totalPrice, @@ -69,107 +61,211 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct ] ] ], - "application_context" => [ - "cancel_url" => route('payment.Cancel'), - "return_url" => route('payment.PayPalSuccess', ['payment' => $payment->id]), - 'brand_name' => config('app.name', 'CtrlPanel.GG'), - 'shipping_preference' => 'NO_SHIPPING' - ] + 'payment_source' => [ + 'paypal' => [ + 'experience_context' => [ + 'cancel_url' => route('payment.Cancel'), + 'return_url' => URL::temporarySignedRoute('payment.PayPalSuccess', now()->addDay(), ['payment' => $payment->id]), + 'brand_name' => config('app.name', 'CtrlPanel.GG'), + 'shipping_preference' => 'NO_SHIPPING', + ], + ], + ], ]; - - try { - // Call API with your client and get a response for your call - $response = self::getPayPalClient()->execute($request); - - // check for any errors in the response - if ($response->statusCode != 201) { - throw new \Exception($response->statusCode); + $response = self::paypalRequest('post', '/v2/checkout/orders', $payload, [ + 'Prefer' => 'return=representation', + ]); + if ($response->status() !== 201) { + throw new \Exception('Unexpected PayPal status: ' . $response->status()); } - // make sure the link is not empty - if (empty($response->result->links[1]->href)) { + $body = $response->json(); + $approveUrl = collect($body['links'] ?? [])->first( + fn (array $link): bool => in_array($link['rel'] ?? null, ['approve', 'payer-action'], true) + )['href'] ?? null; + + if (! is_string($approveUrl) || $approveUrl === '') { throw new \Exception('No redirect link found'); } - return $response->result->links[1]->href; - } catch (HttpException $ex) { - Log::error('PayPal Payment: ' . $ex->getMessage()); + return $approveUrl; + } catch (\Throwable $ex) { + Log::error('PayPal Payment: ' . $ex->getMessage(), [ + 'payment_id' => $payment->id, + ]); throw new \Exception('PayPal Payment: ' . $ex->getMessage()); } } - static function PaypalSuccess(Request $laravelRequest): void + public static function PaypalSuccess(Request $laravelRequest): \Illuminate\Http\RedirectResponse { - $user = Auth::user(); - $user = User::findOrFail($user->id); + if (! $laravelRequest->hasValidSignatureWhileIgnoring(['token', 'PayerID'])) { + abort(403); + } $payment = Payment::findOrFail($laravelRequest->payment); - $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + if ($payment->payment_method !== 'PayPal') { + abort(403); + } + + if ($payment->status === PaymentStatus::PAID) { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); + } - $request = new OrdersCaptureRequest($laravelRequest->input('token')); - $request->prefer('return=representation'); + $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + $paymentOwner = User::findOrFail($payment->user_id); try { - // Call API with your client and get a response for your call - $response = self::getPayPalClient()->execute($request); - if ($response->statusCode == 201 || $response->statusCode == 200) { - //update payment - $payment->update([ - 'status' => PaymentStatus::PAID, - 'payment_id' => $response->result->id, - ]); + $orderId = (string) $laravelRequest->input('token', ''); + if ($orderId === '') { + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your PayPal payment because the callback was incomplete.') + ); + } + + $response = self::paypalRequest( + 'post', + '/v2/checkout/orders/' . $orderId . '/capture', + [], + ['Prefer' => 'return=representation'] + ); + + if ($response->status() == 201 || $response->status() == 200) { + $result = $response->json(); + $customId = data_get($result, 'purchase_units.0.custom_id'); + if ($customId !== $payment->id) { + abort(403); + } + self::assertCapturedAmountMatches($payment, $result); - event(new UserUpdateCreditsEvent($user)); - event(new PaymentEvent($user, $payment, $shopProduct)); + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'status' => PaymentStatus::PAID->value, + 'payment_id' => data_get($result, 'id'), + ]); + + if ($updated === 0) { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); + } + + $payment = $payment->fresh(); + $paymentOwner->notify(new ConfirmPaymentNotification($payment)); + event(new UserUpdateCreditsEvent($paymentOwner)); + event(new PaymentEvent($paymentOwner, $payment, $shopProduct)); // redirect to the payment success page with success message - Redirect::route('home')->with('success', 'Payment successful')->send(); - } elseif (config('app.env') == 'local') { - // If call returns body in response, you can get the deserialized version from the result attribute of the response - $payment->delete(); - dd($response); - } else { - $payment->update([ - 'status' => PaymentStatus::CANCELED, - 'payment_id' => $response->result->id, - ]); - abort(500); - } - } catch (HttpException $ex) { - if (config('app.env') == 'local') { - echo $ex->statusCode; - $payment->delete(); - dd($ex->getMessage()); - } else { - $payment->update([ - 'status' => PaymentStatus::CANCELED, - 'payment_id' => $response->result->id, - ]); - abort(422); + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); } + + Log::warning('PayPal capture returned an unexpected status.', [ + 'payment_id' => $payment->id, + 'status' => $response->status(), + 'body' => $response->json() ?: $response->body(), + ]); + } catch (\Throwable $ex) { + Log::error('PayPal capture failed', [ + 'payment_id' => $payment->id, + 'message' => $ex->getMessage(), + ]); + } + + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your PayPal payment. If you were charged, please contact support.') + ); + } + + private static function assertCapturedAmountMatches(Payment $payment, array|object $result): void + { + $purchaseUnit = data_get($result, 'purchase_units.0'); + $capturedAmount = data_get($purchaseUnit, 'payments.captures.0.amount') + ?? data_get($purchaseUnit, 'amount'); + + if ($capturedAmount === null) { + throw new \Exception('PayPal capture amount missing.'); + } + + $expectedAmount = number_format($payment->total_price / 1000, 2, '.', ''); + $actualAmount = number_format((float) data_get($capturedAmount, 'value', 0), 2, '.', ''); + $actualCurrency = strtoupper((string) data_get($capturedAmount, 'currency_code', '')); + + if ($actualCurrency !== strtoupper($payment->currency_code) || $actualAmount !== $expectedAmount) { + Log::critical('PayPal payment amount mismatch detected', [ + 'payment_id' => $payment->id, + 'expected_amount' => $expectedAmount, + 'received_amount' => $actualAmount, + 'expected_currency' => strtoupper($payment->currency_code), + 'received_currency' => $actualCurrency, + ]); + + throw new \Exception('PayPal payment amount verification failed.'); } } - static function getPayPalClient(): PayPalHttpClient + private static function paypalRequest(string $method, string $path, array $payload = [], array $headers = []) { + $request = Http::acceptJson() + ->withToken(self::getPayPalAccessToken()) + ->withHeaders(array_merge([ + 'Content-Type' => 'application/json', + ], $headers)) + ->timeout(30); + + return match (strtolower($method)) { + 'get' => $request->get(self::getPayPalApiBaseUrl() . $path, $payload), + 'post' => $request->post(self::getPayPalApiBaseUrl() . $path, $payload), + default => throw new \InvalidArgumentException('Unsupported PayPal HTTP method.'), + }; + } + + private static function getPayPalAccessToken(): string + { + $response = Http::asForm() + ->acceptJson() + ->withBasicAuth(self::getPaypalClientId(), self::getPaypalClientSecret()) + ->timeout(30) + ->post(self::getPayPalApiBaseUrl() . '/v1/oauth2/token', [ + 'grant_type' => 'client_credentials', + ]); + + if (! $response->successful()) { + Log::error('PayPal auth failed', [ + 'status' => $response->status(), + 'body' => $response->json() ?: $response->body(), + ]); + + throw new \Exception('Failed to authenticate with PayPal.'); + } + + $token = $response->json('access_token'); + if (! is_string($token) || $token === '') { + throw new \Exception('PayPal access token missing.'); + } + + return $token; + } - $environment = config('app.env') == 'local' - ? new SandboxEnvironment(self::getPaypalClientId(), self::getPaypalClientSecret()) - : new ProductionEnvironment(self::getPaypalClientId(), self::getPaypalClientSecret()); - return new PayPalHttpClient($environment); + private static function getPayPalApiBaseUrl(): string + { + return self::getPayPalMode() === 'sandbox' + ? 'https://api-m.sandbox.paypal.com' + : 'https://api-m.paypal.com'; } + /** * @return string */ static function getPaypalClientId(): string { $settings = new PayPalSettings(); - return config('app.env') == 'local' ? $settings->sandbox_client_id : $settings->client_id; + return self::getPayPalMode() === 'sandbox' ? $settings->sandbox_client_id : $settings->client_id; } /** * @return string @@ -177,6 +273,18 @@ static function getPaypalClientId(): string static function getPaypalClientSecret(): string { $settings = new PayPalSettings(); - return config('app.env') == 'local' ? $settings->sandbox_client_secret : $settings->client_secret; + return self::getPayPalMode() === 'sandbox' ? $settings->sandbox_client_secret : $settings->client_secret; + } + + private static function getPayPalMode(): string + { + $settings = new PayPalSettings(); + + return $settings->mode === 'sandbox' ? 'sandbox' : 'live'; + } + + private static function getCallbackRedirectRoute(): string + { + return Auth::check() ? 'home' : 'login'; } } diff --git a/app/Extensions/PaymentGateways/PayPal/PayPalSettings.php b/app/Extensions/PaymentGateways/PayPal/PayPalSettings.php index 9d22dec26..6d3daf32a 100644 --- a/app/Extensions/PaymentGateways/PayPal/PayPalSettings.php +++ b/app/Extensions/PaymentGateways/PayPal/PayPalSettings.php @@ -7,6 +7,7 @@ class PayPalSettings extends Settings { public bool $enabled = false; + public string $mode = 'live'; public ?string $client_id; public ?string $client_secret; public ?string $sandbox_client_id; @@ -34,6 +35,15 @@ public static function getOptionInputData() 'label' => 'Client ID', 'description' => 'The Client ID of your PayPal App', ], + 'mode' => [ + 'type' => 'select', + 'label' => 'Mode', + 'description' => 'Choose whether PayPal should use the live or sandbox API.', + 'options' => [ + 'live' => 'Live', + 'sandbox' => 'Sandbox', + ], + ], 'client_secret' => [ 'type' => 'string', 'label' => 'Client Secret', @@ -47,12 +57,12 @@ public static function getOptionInputData() 'sandbox_client_id' => [ 'type' => 'string', 'label' => 'Sandbox Client ID', - 'description' => 'The Sandbox Client ID used when app_env = local', + 'description' => 'The Sandbox Client ID used when PayPal mode is set to Sandbox', ], 'sandbox_client_secret' => [ 'type' => 'string', 'label' => 'Sandbox Client Secret', - 'description' => 'The Sandbox Client Secret used when app_env = local', + 'description' => 'The Sandbox Client Secret used when PayPal mode is set to Sandbox', ], ]; } diff --git a/app/Extensions/PaymentGateways/PayPal/migrations/2026_03_27_120000_add_paypal_mode_setting.php b/app/Extensions/PaymentGateways/PayPal/migrations/2026_03_27_120000_add_paypal_mode_setting.php new file mode 100644 index 000000000..6e2d7d0de --- /dev/null +++ b/app/Extensions/PaymentGateways/PayPal/migrations/2026_03_27_120000_add_paypal_mode_setting.php @@ -0,0 +1,16 @@ +migrator->add('paypal.mode', app()->environment('local') ? 'sandbox' : 'live'); + } + + public function down(): void + { + $this->migrator->delete('paypal.mode'); + } +} diff --git a/app/Extensions/PaymentGateways/PayPal/routes.php b/app/Extensions/PaymentGateways/PayPal/routes.php index 75b1da191..ae93d1634 100644 --- a/app/Extensions/PaymentGateways/PayPal/routes.php +++ b/app/Extensions/PaymentGateways/PayPal/routes.php @@ -3,11 +3,11 @@ use Illuminate\Support\Facades\Route; use App\Extensions\PaymentGateways\PayPal\PayPalExtension; -Route::middleware(['web', 'auth'])->group(function () { +Route::middleware(['web'])->group(function () { Route::get( 'payment/PayPalSuccess', function () { - PayPalExtension::PaypalSuccess(request()); + return PayPalExtension::PaypalSuccess(request()); } )->name('payment.PayPalSuccess'); }); diff --git a/app/Extensions/PaymentGateways/Stripe/StripeExtension.php b/app/Extensions/PaymentGateways/Stripe/StripeExtension.php index 5218e55b1..132cc6bec 100644 --- a/app/Extensions/PaymentGateways/Stripe/StripeExtension.php +++ b/app/Extensions/PaymentGateways/Stripe/StripeExtension.php @@ -12,9 +12,13 @@ use App\Traits\Coupon as CouponTrait; use App\Notifications\ConfirmPaymentNotification; use Exception; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\URL; use Stripe\Exception\SignatureVerificationException; use Stripe\Stripe; use Stripe\StripeClient; @@ -80,7 +84,7 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct ], 'mode' => 'payment', - 'success_url' => route('payment.StripeSuccess', ['payment' => $payment->id]) . '&session_id={CHECKOUT_SESSION_ID}', + 'success_url' => URL::temporarySignedRoute('payment.StripeSuccess', now()->addDay(), ['payment' => $payment->id]) . '&session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('payment.Cancel'), 'payment_intent_data' => [ 'metadata' => [ @@ -95,94 +99,129 @@ public static function getRedirectUrl(Payment $payment, ShopProduct $shopProduct /** * @param Request $request */ - public static function StripeSuccess(Request $request) + public static function StripeSuccess(Request $request): RedirectResponse { - $user = Auth::user(); - $user = User::findOrFail($user->id); + if (! $request->hasValidSignatureWhileIgnoring(['session_id'])) { + abort(403); + } + $payment = Payment::findOrFail($request->input('payment')); + if ($payment->payment_method !== 'Stripe') { + abort(403); + } + $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); + $paymentOwner = User::findOrFail($payment->user_id); - Redirect::route('home')->with('success', 'Please wait for success')->send(); + $sessionId = (string) $request->input('session_id', ''); + if ($sessionId === '') { + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your Stripe payment because the callback was incomplete.') + ); + } $stripeClient = self::getStripeClient(); try { //get stripe data - $paymentSession = $stripeClient->checkout->sessions->retrieve($request->input('session_id')); + $paymentSession = $stripeClient->checkout->sessions->retrieve($sessionId); $paymentIntent = $stripeClient->paymentIntents->retrieve($paymentSession->payment_intent); - //get DB entry of this payment ID if existing - $paymentDbEntry = Payment::where('payment_id', $paymentSession->payment_intent)->count(); + self::assertStripePaymentMatches($payment, $paymentIntent); - // check if payment is 100% completed and payment does not exist in db already - if ($paymentSession->status == 'complete' && $paymentIntent->status == 'succeeded' && $paymentDbEntry == 0) { - - //update payment - $payment->update([ - 'payment_id' => $paymentSession->payment_intent, - 'status' => PaymentStatus::PAID, - ]); + if ($paymentIntent->status == 'succeeded') { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => $paymentSession->payment_intent, + 'status' => PaymentStatus::PAID->value, + ]); - //payment notification - $user->notify(new ConfirmPaymentNotification($payment)); - event(new UserUpdateCreditsEvent($user)); - event(new PaymentEvent($user, $payment, $shopProduct)); + if ($updated > 0) { + $payment = $payment->fresh(); + $paymentOwner->notify(new ConfirmPaymentNotification($payment)); + event(new UserUpdateCreditsEvent($paymentOwner)); + event(new PaymentEvent($paymentOwner, $payment, $shopProduct)); + } - //redirect back to home - Redirect::route('home')->with('success', 'Payment successful')->send(); - } else { - if ($paymentIntent->status == 'processing') { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); + } - //update payment - $payment->update([ + if ($paymentIntent->status == 'processing') { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ 'payment_id' => $paymentSession->payment_intent, - 'status' => PaymentStatus::PROCESSING, + 'status' => PaymentStatus::PROCESSING->value, ]); - event(new PaymentEvent($user, $payment, $shopProduct)); + if ($updated > 0) { + $payment = $payment->fresh(); + event(new PaymentEvent($paymentOwner, $payment, $shopProduct)); - Redirect::route('home')->with('success', 'Your payment is being processed')->send(); + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Your payment is being processed'); } - if ($paymentDbEntry == 0 && $paymentIntent->status != 'processing') { - $stripeClient->paymentIntents->cancel($paymentIntent->id); + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); + } - //redirect back to home - Redirect::route('home')->with('info', __('Your payment has been canceled!'))->send(); - } else { - abort(402); - } + $freshPayment = $payment->fresh(); + if ($freshPayment->status === PaymentStatus::PAID) { + return Redirect::route(self::getCallbackRedirectRoute())->with('success', 'Payment successful'); } - } catch (Exception $e) { - if (config('app.env') == 'local') { - dd($e->getMessage()); - } else { - abort(422); + + if ($paymentIntent->status != 'processing') { + $stripeClient->paymentIntents->cancel($paymentIntent->id); + + //redirect back to home + return Redirect::route(self::getCallbackRedirectRoute())->with('info', __('Your payment has been canceled!')); } + } catch (Exception $e) { + Log::error('Stripe success callback failed.', [ + 'payment_id' => $payment->id, + 'session_id' => $sessionId, + 'message' => $e->getMessage(), + ]); + + return Redirect::route(self::getCallbackRedirectRoute())->with( + 'error', + __('We could not confirm your Stripe payment. If you were charged, please contact support.') + ); } } /** * @param Request $request */ - public static function handleStripePaymentSuccessHook($paymentIntent) + public static function handleStripePaymentSuccessHook($paymentIntent): JsonResponse { try { - $payment = Payment::where('id', $paymentIntent->metadata->payment_id)->with('user')->first(); - $user = User::where('id', $payment->user_id)->first(); + $payment = Payment::findOrFail($paymentIntent->metadata->payment_id); + if ($payment->payment_method !== 'Stripe') { + abort(403); + } + + $user = User::findOrFail($payment->user_id); $shopProduct = ShopProduct::findOrFail($payment->shop_item_product_id); - if ($paymentIntent->status == 'succeeded' && $payment->status == 'processing') { + self::assertStripePaymentMatches($payment, $paymentIntent); - //update payment db entry status - $payment->update([ - 'payment_id' => $payment->payment_id ?? $paymentIntent->id, - 'status' => PaymentStatus::PAID, - ]); + if ($paymentIntent->status == 'succeeded') { + $updated = Payment::whereKey($payment->id) + ->where('status', '!=', PaymentStatus::PAID->value) + ->update([ + 'payment_id' => $payment->payment_id ?? $paymentIntent->id, + 'status' => PaymentStatus::PAID->value, + ]); - //payment notification - $user->notify(new ConfirmPaymentNotification($payment)); - event(new UserUpdateCreditsEvent($user)); - event(new PaymentEvent($user, $payment, $shopProduct)); + if ($updated > 0) { + $payment = $payment->fresh(); + + //payment notification + $user->notify(new ConfirmPaymentNotification($payment)); + event(new UserUpdateCreditsEvent($user)); + event(new PaymentEvent($user, $payment, $shopProduct)); + } } // return 200 @@ -195,7 +234,7 @@ public static function handleStripePaymentSuccessHook($paymentIntent) /** * @param Request $request */ - public static function StripeWebhooks(Request $request) + public static function StripeWebhooks(Request $request): JsonResponse { Stripe::setApiKey(self::getStripeSecret()); @@ -222,10 +261,9 @@ public static function StripeWebhooks(Request $request) switch ($event->type) { case 'payment_intent.succeeded': $paymentIntent = $event->data->object; // contains a \Stripe\PaymentIntent - self::handleStripePaymentSuccessHook($paymentIntent); - break; + return self::handleStripePaymentSuccessHook($paymentIntent); default: - echo 'Received unknown event type ' . $event->type; + return response()->json(['success' => true]); } } @@ -244,7 +282,7 @@ public static function getStripeSecret() { $settings = new StripeSettings(); - return config('app.env') == 'local' + return self::getStripeMode() === 'test' ? $settings->test_secret_key : $settings->secret_key; } @@ -255,10 +293,22 @@ public static function getStripeSecret() public static function getStripeEndpointSecret() { $settings = new StripeSettings(); - return env('APP_ENV') == 'local' + return self::getStripeMode() === 'test' ? $settings->test_endpoint_secret : $settings->endpoint_secret; } + + private static function getStripeMode(): string + { + $settings = new StripeSettings(); + + return $settings->mode === 'test' ? 'test' : 'live'; + } + + private static function getCallbackRedirectRoute(): string + { + return Auth::check() ? 'home' : 'login'; + } /** * @param $amount * @param $currencyCode @@ -385,4 +435,31 @@ protected static function convertAmount(float $amount, string $currency): int return $amount / 10; } + + private static function assertStripePaymentMatches(Payment $payment, object $paymentIntent): void + { + if (($paymentIntent->metadata->payment_id ?? null) !== $payment->id) { + throw new Exception('Stripe payment metadata mismatch.'); + } + + $actualCurrency = strtoupper((string) ($paymentIntent->currency ?? '')); + $expectedCurrency = strtoupper($payment->currency_code); + $expectedAmount = self::convertAmount((float) $payment->total_price, $expectedCurrency); + $actualAmount = (int) (($paymentIntent->amount_received ?? 0) > 0 + ? $paymentIntent->amount_received + : ($paymentIntent->amount ?? 0)); + + if ($actualCurrency !== $expectedCurrency || $actualAmount !== $expectedAmount) { + Log::critical('Stripe payment amount mismatch detected', [ + 'payment_id' => $payment->id, + 'expected_amount' => $expectedAmount, + 'received_amount' => $actualAmount, + 'expected_currency' => $expectedCurrency, + 'received_currency' => $actualCurrency, + 'user_id' => $payment->user_id, + ]); + + throw new Exception('Stripe payment amount verification failed.'); + } + } } diff --git a/app/Extensions/PaymentGateways/Stripe/StripeSettings.php b/app/Extensions/PaymentGateways/Stripe/StripeSettings.php index 7cf6de5f6..9ae898b87 100644 --- a/app/Extensions/PaymentGateways/Stripe/StripeSettings.php +++ b/app/Extensions/PaymentGateways/Stripe/StripeSettings.php @@ -8,6 +8,7 @@ class StripeSettings extends Settings { public bool $enabled = false; + public string $mode = 'live'; public ?string $secret_key; public ?string $endpoint_secret; public ?string $test_secret_key; @@ -30,6 +31,15 @@ public static function getOptionInputData() 'label' => 'Secret Key', 'description' => 'The Secret Key of your Stripe App', ], + 'mode' => [ + 'type' => 'select', + 'label' => 'Mode', + 'description' => 'Choose whether Stripe should use the live or test API.', + 'options' => [ + 'live' => 'Live', + 'test' => 'Test', + ], + ], 'endpoint_secret' => [ 'type' => 'string', 'label' => 'Endpoint Secret', @@ -38,12 +48,12 @@ public static function getOptionInputData() 'test_secret_key' => [ 'type' => 'string', 'label' => 'Test Secret Key', - 'description' => 'The Test Secret Key used when app_env = local', + 'description' => 'The Test Secret Key used when Stripe mode is set to Test', ], 'test_endpoint_secret' => [ 'type' => 'string', 'label' => 'Test Endpoint Secret', - 'description' => 'The Test Endpoint Secret used when app_env = local', + 'description' => 'The Test Endpoint Secret used when Stripe mode is set to Test', ], 'enabled' => [ 'type' => 'boolean', diff --git a/app/Extensions/PaymentGateways/Stripe/migrations/2026_03_27_120100_add_stripe_mode_setting.php b/app/Extensions/PaymentGateways/Stripe/migrations/2026_03_27_120100_add_stripe_mode_setting.php new file mode 100644 index 000000000..fcfae9eda --- /dev/null +++ b/app/Extensions/PaymentGateways/Stripe/migrations/2026_03_27_120100_add_stripe_mode_setting.php @@ -0,0 +1,16 @@ +migrator->add('stripe.mode', app()->environment('local') ? 'test' : 'live'); + } + + public function down(): void + { + $this->migrator->delete('stripe.mode'); + } +} diff --git a/app/Extensions/PaymentGateways/Stripe/routes.php b/app/Extensions/PaymentGateways/Stripe/routes.php index 742940d8c..b7f6fec3a 100644 --- a/app/Extensions/PaymentGateways/Stripe/routes.php +++ b/app/Extensions/PaymentGateways/Stripe/routes.php @@ -3,11 +3,11 @@ use Illuminate\Support\Facades\Route; use App\Extensions\PaymentGateways\Stripe\StripeExtension; -Route::middleware(['web', 'auth'])->group(function () { +Route::middleware(['web'])->group(function () { Route::get( 'payment/StripeSuccess', function () { - StripeExtension::StripeSuccess(request()); + return StripeExtension::StripeSuccess(request()); } )->name('payment.StripeSuccess'); }); @@ -15,5 +15,5 @@ function () { // Stripe WebhookRoute -> validation in Route Handler Route::post('payment/StripeWebhooks', function () { - StripeExtension::StripeWebhooks(request()); + return StripeExtension::StripeWebhooks(request()); })->name('payment.StripeWebhooks'); diff --git a/app/Helpers/CallHomeHelper.php b/app/Helpers/CallHomeHelper.php index c773d174f..82f72d445 100644 --- a/app/Helpers/CallHomeHelper.php +++ b/app/Helpers/CallHomeHelper.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; class CallHomeHelper { @@ -22,24 +23,45 @@ class CallHomeHelper public static function callHomeOnce(): void { $flagFile = storage_path('app/callhome_sent.flag'); - if (file_exists($flagFile)) { + $handle = @fopen($flagFile, 'c+'); + if ($handle === false) { return; } try { - $url = parse_url(config('app.url'), PHP_URL_HOST); - $urlHash = md5($url); - $promise = Http::async()->post('https://utils.ctrlpanel.gg/callhome.php', [ - 'id' => $urlHash, + if (! flock($handle, LOCK_EX)) { + fclose($handle); + return; + } + + $existingContents = stream_get_contents($handle); + if ($existingContents !== false && trim($existingContents) !== '') { + flock($handle, LOCK_UN); + fclose($handle); + return; + } + + $installationId = (string) Str::uuid(); + $response = Http::timeout(5)->connectTimeout(5)->post('https://utils.ctrlpanel.gg/callhome.php', [ + 'id' => $installationId, ]); - $response = $promise->wait(); + + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, $installationId . PHP_EOL . $response->body()); + fflush($handle); + flock($handle, LOCK_UN); + fclose($handle); + Log::info('CallHome: request sent'); - file_put_contents($flagFile, $response->body()); } catch (\Exception $e) { Log::error('CallHome fail: ' . $e->getMessage()); + if (is_resource($handle)) { + flock($handle, LOCK_UN); + fclose($handle); + } } } } - diff --git a/app/Helpers/CurrencyHelper.php b/app/Helpers/CurrencyHelper.php index dfa1c7a94..bfbe8ae43 100644 --- a/app/Helpers/CurrencyHelper.php +++ b/app/Helpers/CurrencyHelper.php @@ -16,9 +16,13 @@ private function getEffectiveLocale($locale = null, $ignoreOverride = false) $effectiveLocale = $locale ?: str_replace('_', '-', app()->getLocale()); if (!$ignoreOverride) { - $override = resolve(\App\Settings\GeneralSettings::class)->currency_format_override ?? null; - if ($override) { - $effectiveLocale = $override; + try { + $override = resolve(\App\Settings\GeneralSettings::class)->currency_format_override ?? null; + if ($override) { + $effectiveLocale = $override; + } + } catch (\Throwable) { + // Fall back to the active locale if settings are not yet readable. } } @@ -50,7 +54,7 @@ public function formatForForm($amount, $decimals = 2) public function prepareForDatabase($amount) { - return (int)($amount * 1000); + return (int) round($amount * 1000); } public function formatToCurrency(int $amount, $currency_code, $locale = null) diff --git a/app/Http/Controllers/Admin/ActivityLogController.php b/app/Http/Controllers/Admin/ActivityLogController.php index 79a9353bb..81eee14d2 100644 --- a/app/Http/Controllers/Admin/ActivityLogController.php +++ b/app/Http/Controllers/Admin/ActivityLogController.php @@ -10,7 +10,6 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; use Spatie\Activitylog\Models\Activity; -use Illuminate\Pagination\LengthAwarePaginator; class ActivityLogController extends Controller { @@ -24,49 +23,31 @@ public function index(Request $request) { $this->checkPermission(self::VIEW_PERMISSION); - $cronLogs = Storage::disk('logs')->exists('cron.log') ? Storage::disk('logs')->get('cron.log') : null; - - if ($request->input('search')) { - $searchTerm = $request->input('search'); - - // Pre-fetch logs and decode JSON properties - $logs = Activity::all()->filter(function ($log) use ($searchTerm) { - $properties = json_decode($log->properties, true); - - // Check if search term exists in attributes or old values - $attributesMatch = isset($properties['attributes']) && - collect($properties['attributes'])->contains(fn($value) => str_contains(strtolower($value), strtolower($searchTerm))); - - $oldMatch = isset($properties['old']) && - collect($properties['old'])->contains(fn($value) => str_contains(strtolower($value), strtolower($searchTerm))); - - return str_contains(strtolower($log->description), strtolower($searchTerm)) || - str_contains(strtolower(optional($log->causer)->name), strtolower($searchTerm)) || - $attributesMatch || $oldMatch; + $query = Activity::query() + ->with('causer') + ->orderBy('created_at', 'desc'); + + if ($request->filled('search')) { + $searchTerm = trim((string) $request->input('search')); + $query->where(function ($builder) use ($searchTerm) { + $like = '%' . $searchTerm . '%'; + + $builder + ->where('description', 'like', $like) + ->orWhere('properties', 'like', $like) + ->orWhereHas('causer', function ($causerQuery) use ($like) { + $causerQuery->where('name', 'like', $like); + }); }); - - // Paginate manually - $perPage = 20; - $currentPage = LengthAwarePaginator::resolveCurrentPage(); - $currentItems = $logs->slice(($currentPage - 1) * $perPage, $perPage); - $query = new LengthAwarePaginator( - $currentItems, - $logs->count(), - $perPage, - $currentPage, - ['path' => LengthAwarePaginator::resolveCurrentPath()] - ); - } else { - $query = Activity::orderBy('created_at', 'desc')->paginate(20); } + $logs = $query->paginate(20)->withQueryString(); + return view('admin.activitylogs.index')->with([ - 'logs' => $query, + 'logs' => $logs, 'cronlogs' => $cronLogs, ]); - - } /** diff --git a/app/Http/Controllers/Admin/ApplicationApiController.php b/app/Http/Controllers/Admin/ApplicationApiController.php index ad62612fb..2e4c63c1a 100644 --- a/app/Http/Controllers/Admin/ApplicationApiController.php +++ b/app/Http/Controllers/Admin/ApplicationApiController.php @@ -6,6 +6,7 @@ use App\Models\ApplicationApi; use App\Settings\LocaleSettings; use Exception; +use Illuminate\Support\Carbon; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -41,7 +42,9 @@ public function create() { $this->checkPermission(self::WRITE_PERMISSION); - return view('admin.api.create'); + return view('admin.api.create', [ + 'availableAbilities' => ApplicationApi::abilityOptions(), + ]); } /** @@ -56,13 +59,22 @@ public function store(Request $request) $request->validate([ 'memo' => 'nullable|string|max:60', + 'abilities' => 'required|array|min:1', + 'abilities.*' => 'required|string|in:' . implode(',', ApplicationApi::availableAbilities()), + 'expires_at' => 'nullable|date|after:now', ]); - ApplicationApi::create([ - 'memo' => $request->input('memo'), - ]); - - return redirect()->route('admin.api.index')->with('success', __('api key created!')); + [, $plainTextToken] = ApplicationApi::issue( + null, + $request->input('memo'), + $request->input('abilities', []), + $request->filled('expires_at') ? Carbon::parse($request->input('expires_at')) : null, + ); + + return redirect() + ->route('admin.api.index') + ->with('success', __('API token created!')) + ->with('plain_text_api_token', $plainTextToken); } /** @@ -73,7 +85,7 @@ public function store(Request $request) */ public function show(ApplicationApi $applicationApi) { - // + abort(404); } /** @@ -87,6 +99,7 @@ public function edit(ApplicationApi $applicationApi) $this->checkPermission(self::WRITE_PERMISSION); return view('admin.api.edit', [ 'applicationApi' => $applicationApi, + 'availableAbilities' => ApplicationApi::abilityOptions(), ]); } @@ -103,11 +116,32 @@ public function update(Request $request, ApplicationApi $applicationApi) $request->validate([ 'memo' => 'nullable|string|max:60', + 'abilities' => 'required|array|min:1', + 'abilities.*' => 'required|string|in:' . implode(',', ApplicationApi::availableAbilities()), + 'expires_at' => 'nullable|date|after:now', + 'revoked' => 'nullable|boolean', + 'rotate_token' => 'nullable|boolean', + ]); + + $applicationApi->update([ + 'memo' => $request->input('memo'), + 'abilities' => $request->input('abilities', []), + 'expires_at' => $request->filled('expires_at') ? Carbon::parse($request->input('expires_at')) : null, + 'revoked_at' => $request->boolean('revoked') ? now() : null, ]); - $applicationApi->update($request->all()); + $plainTextToken = null; + if ($request->boolean('rotate_token')) { + $plainTextToken = $applicationApi->rotate($applicationApi->expires_at); + } - return redirect()->route('admin.api.index')->with('success', __('api key updated!')); + $redirect = redirect()->route('admin.api.index')->with('success', __('API token updated!')); + + if ($plainTextToken) { + $redirect->with('plain_text_api_token', $plainTextToken); + } + + return $redirect; } /** @@ -133,15 +167,19 @@ public function destroy(ApplicationApi $applicationApi) */ public function dataTable(Request $request) { - $this->checkAnyPermission([self::READ_PERMISSION,self::WRITE_PERMISSION]); + $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION]); $query = ApplicationApi::query(); return datatables($query) ->addColumn('actions', function (ApplicationApi $apiKey) { + if (! auth()->user()?->can(self::WRITE_PERMISSION)) { + return ''; + } + return ' - -
+ + '.csrf_field().' '.method_field('DELETE').' @@ -149,12 +187,27 @@ public function dataTable(Request $request) '; }) ->editColumn('token', function (ApplicationApi $apiKey) { - return "{$apiKey->token}"; + return "{$apiKey->display_token_identifier}"; }) ->editColumn('last_used', function (ApplicationApi $apiKey) { return $apiKey->last_used ? $apiKey->last_used->diffForHumans() : ''; }) - ->rawColumns(['actions', 'token']) + ->addColumn('abilities', function (ApplicationApi $apiKey) { + return e(implode(', ', $apiKey->abilities ?? [])); + }) + ->editColumn('expires_at', function (ApplicationApi $apiKey) { + return $apiKey->expires_at ? $apiKey->expires_at->diffForHumans() : __('Never'); + }) + ->addColumn('status', function (ApplicationApi $apiKey) { + $color = match ($apiKey->status_label) { + 'Revoked' => 'danger', + 'Expired' => 'warning', + default => 'success', + }; + + return '' . e(__($apiKey->status_label)) . ''; + }) + ->rawColumns(['actions', 'token', 'status']) ->make(); } } diff --git a/app/Http/Controllers/Admin/CouponController.php b/app/Http/Controllers/Admin/CouponController.php index acbcb9967..ced2b97e3 100644 --- a/app/Http/Controllers/Admin/CouponController.php +++ b/app/Http/Controllers/Admin/CouponController.php @@ -9,6 +9,8 @@ use App\Traits\Coupon as CouponTrait; use Illuminate\Http\Request; use Carbon\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; class CouponController extends Controller { @@ -66,30 +68,29 @@ public function store(Request $request) return redirect()->back()->with('error', __('At least one of the two code inputs must be provided.'))->withInput($request->all()); } - $request->validate($rules); + $validated = $request->validate($rules); if (array_key_exists('range_codes', $rules)) { - $data = []; - $coupons = Coupon::generateRandomCoupon($random_codes_amount); - - // Scroll through all the randomly generated coupons. - foreach ($coupons as $coupon) { - $data[] = [ - 'code' => $coupon, - 'type' => $request->input('type'), - 'value' => $request->input('value'), - 'max_uses' => $request->input('max_uses'), - 'max_uses_per_user' => $this->normalizeMaxUsesPerUser($request), - 'expires_at' => $request->input('expires_at'), - 'created_at' => Carbon::now(), // Does not fill in by itself when using the 'insert' method. - 'updated_at' => Carbon::now() - ]; - } - Coupon::insert($data); + $data = $validated; + $data['max_uses_per_user'] = $this->normalizeMaxUsesPerUser($request); + DB::transaction(function () use ($data, $random_codes_amount) { + $coupons = Coupon::generateRandomCoupon($random_codes_amount); + + foreach ($coupons as $coupon) { + Coupon::create([ + 'code' => $coupon, + 'type' => $data['type'], + 'value' => $data['value'], + 'max_uses' => $data['max_uses'], + 'max_uses_per_user' => $data['max_uses_per_user'], + 'expires_at' => $data['expires_at'] ?? null, + ]); + } + }); } else { - $data = $request->except('_token'); - $data['max_uses_per_user'] = $this->normalizeMaxUsesPerUser($request); - Coupon::create($data); + $data = $validated; + $data['max_uses_per_user'] = $this->normalizeMaxUsesPerUser($request); + Coupon::create($data); } return redirect()->route('admin.coupons.index')->with('success', __("The coupon's was registered successfully.")); @@ -103,7 +104,7 @@ public function store(Request $request) */ public function show(Coupon $coupon) { - // + abort(404); } /** @@ -118,7 +119,7 @@ public function edit(Coupon $coupon) return view('admin.coupons.edit', [ 'coupon' => $coupon, - 'expired_at' => $coupon->expires_at ? Carbon::createFromTimestamp($coupon->expires_at) : null + 'expired_at' => $coupon->expires_at ? Carbon::createFromTimestamp((int) $coupon->expires_at) : null ]); } @@ -135,7 +136,7 @@ public function update(Request $request, Coupon $coupon) $coupon_code = $request->input('code'); $random_codes_amount = $request->input('range_codes'); - $rules = $this->requestRules($request); + $rules = $this->requestRules($request, $coupon); // If for some reason you pass both fields at once. if ($coupon_code && $random_codes_amount) { @@ -146,10 +147,10 @@ public function update(Request $request, Coupon $coupon) return redirect()->back()->with('error', __('At least one of the two code inputs must be provided.'))->withInput($request->all()); } - $request->validate($rules); - $data = $request->except('_token'); - $data['max_uses_per_user'] = $this->normalizeMaxUsesPerUser($request); - $coupon->update($data); + $validated = $request->validate($rules); + $data = $validated; + $data['max_uses_per_user'] = $this->normalizeMaxUsesPerUser($request); + $coupon->update($data); return redirect()->route('admin.coupons.index')->with('success', __('coupon has been updated!')); } @@ -168,10 +169,14 @@ public function destroy(Coupon $coupon) return redirect()->back()->with('success', __('coupon has been removed!')); } - private function requestRules(Request $request) + private function requestRules(Request $request, ?Coupon $coupon = null) { $coupon_code = $request->input('code'); $random_codes_amount = $request->input('range_codes'); + $valueRules = $request->input('type') === 'amount' + ? ['required', 'numeric', 'min:0', 'max:99999999'] + : ['required', 'numeric', 'between:0,100']; + $rules = [ "type" => "required|string|in:percentage,amount", // Set to -1 for unlimited uses globally, or a positive integer within DB range. @@ -200,14 +205,19 @@ function ($attribute, $value, $fail) { } } ], - "value" => "required|numeric|between:0,100", + "value" => $valueRules, "expires_at" => "nullable|date|after:" . Carbon::now()->format(Coupon::formatDate()) ]; if ($coupon_code) { - $rules['code'] = "required|string|min:4"; + $codeRule = Rule::unique('coupons', 'code'); + if ($coupon) { + $codeRule = $codeRule->ignore($coupon->id); + } + + $rules['code'] = ['required', 'string', 'min:4', $codeRule]; } elseif ($random_codes_amount) { - $rules['range_codes'] = 'required|integer|digits_between:1,100'; + $rules['range_codes'] = 'required|integer|min:1|max:100'; } return $rules; @@ -235,7 +245,7 @@ public function redeem(Request $request) public function dataTable() { - $this->checkAnyPermission([self::WRITE_PERMISSION,self::READ_PERMISSION]); + $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION]); $query = Coupon::selectRaw(' coupons.*, @@ -281,10 +291,10 @@ public function dataTable() return __('Never'); } - return Carbon::createFromTimestamp($coupon->expires_at); + return Carbon::createFromTimestamp((int) $coupon->expires_at); }) ->editColumn('created_at', function(Coupon $coupon) { - return Carbon::createFromTimeString($coupon->created_at); + return $coupon->created_at ? $coupon->created_at->diffForHumans() : ''; }) ->editColumn('code', function (Coupon $coupon) { return "{$coupon->code}"; diff --git a/app/Http/Controllers/Admin/InvoiceController.php b/app/Http/Controllers/Admin/InvoiceController.php index 6e9167eef..546b508db 100644 --- a/app/Http/Controllers/Admin/InvoiceController.php +++ b/app/Http/Controllers/Admin/InvoiceController.php @@ -12,13 +12,17 @@ class InvoiceController extends Controller { + private const VIEW_PERMISSION = 'admin.payments.read'; + public function downloadAllInvoices() { + $this->checkPermission(self::VIEW_PERMISSION); + $zip = new ZipArchive; $zip_save_path = storage_path('invoices.zip'); if ($zip->open($zip_save_path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - Log::error("Failed to create zip archive at path: " . $zipPath); + Log::error("Failed to create zip archive at path: " . $zip_save_path); return response()->json(['message' => 'Failed to create zip archive'], 500); } @@ -45,6 +49,8 @@ public function downloadAllInvoices() public function downloadSingleInvoice(Request $request) { + $this->checkPermission(self::VIEW_PERMISSION); + $id = $request->input('id'); try { $invoice = Invoice::where('payment_id', '=', $id)->firstOrFail(); diff --git a/app/Http/Controllers/Admin/OverViewController.php b/app/Http/Controllers/Admin/OverViewController.php index c467a132d..e3e6c00e1 100644 --- a/app/Http/Controllers/Admin/OverViewController.php +++ b/app/Http/Controllers/Admin/OverViewController.php @@ -17,6 +17,7 @@ use App\Models\Ticket; use App\Models\User; use Carbon\Carbon; +use Illuminate\Support\Collection; class OverViewController extends Controller { @@ -58,111 +59,60 @@ public function index(GeneralSettings $general_settings, CurrencyHelper $currenc //Prepare subCollection 'payments' $counters->put('payments', collect()); - //Get and save payments from last 2 months for later filtering and looping - $payments = Payment::query()->where('created_at', '>=', Carbon::today()->startOfMonth()->subMonth())->where('status', 'paid')->get(); //Prepare collections - $counters['payments']->put('thisMonth', collect()); - $counters['payments']->put('lastMonth', collect()); + $currentMonthStart = Carbon::today()->startOfMonth(); + $nextMonthStart = (clone $currentMonthStart)->addMonth(); + $previousMonthStart = (clone $currentMonthStart)->subMonth(); + $counters['payments']->put('thisMonth', $this->paymentSummary($currentMonthStart, $nextMonthStart)); + $counters['payments']->put('lastMonth', $this->paymentSummary($previousMonthStart, $currentMonthStart)); //Prepare subCollection 'taxPayments' $counters->put('taxPayments', collect()); - //Get and save taxPayments from last 2 years for later filtering and looping - $taxPayments = Payment::query()->where('created_at', '>=', Carbon::today()->startOfYear()->subYear())->where('status', 'paid')->get(); //Prepare collections - $counters['taxPayments']->put('thisYear', collect()); - $counters['taxPayments']->put('lastYear', collect()); - - //Fill out variables for each currency separately - foreach ($payments->where('created_at', '>=', Carbon::today()->startOfMonth()) as $payment) { - $paymentCurrency = $payment->currency_code; - if (! isset($counters['payments']['thisMonth'][$paymentCurrency])) { - $counters['payments']['thisMonth']->put($paymentCurrency, collect()); - $counters['payments']['thisMonth'][$paymentCurrency]->total = 0; - $counters['payments']['thisMonth'][$paymentCurrency]->count = 0; - } - $counters['payments']['thisMonth'][$paymentCurrency]->total += $payment->total_price; - $counters['payments']['thisMonth'][$paymentCurrency]->count++; - } - foreach ($payments->where('created_at', '<', Carbon::today()->startOfMonth()) as $payment) { - $paymentCurrency = $payment->currency_code; - if (! isset($counters['payments']['lastMonth'][$paymentCurrency])) { - $counters['payments']['lastMonth']->put($paymentCurrency, collect()); - $counters['payments']['lastMonth'][$paymentCurrency]->total = 0; - $counters['payments']['lastMonth'][$paymentCurrency]->count = 0; - } - $counters['payments']['lastMonth'][$paymentCurrency]->total += $payment->total_price; - $counters['payments']['lastMonth'][$paymentCurrency]->count++; - } + $currentYearStart = Carbon::today()->startOfYear(); + $nextYearStart = (clone $currentYearStart)->addYear(); + $previousYearStart = (clone $currentYearStart)->subYear(); + $counters['taxPayments']->put('thisYear', $this->taxPaymentSummary($currentYearStart, $nextYearStart)); + $counters['taxPayments']->put('lastYear', $this->taxPaymentSummary($previousYearStart, $currentYearStart)); //sort currencies alphabetically and set some additional variables $counters['payments']['thisMonth'] = $counters['payments']['thisMonth']->sortKeys(); - $counters['payments']['thisMonth']->timeStart = Carbon::today()->startOfMonth()->toDateString(); + $counters['payments']['thisMonth']->timeStart = $currentMonthStart->toDateString(); $counters['payments']['thisMonth']->timeEnd = Carbon::today()->toDateString(); $counters['payments']['lastMonth'] = $counters['payments']['lastMonth']->sortKeys(); - $counters['payments']['lastMonth']->timeStart = Carbon::today()->startOfMonth()->subMonth()->toDateString(); - $counters['payments']['lastMonth']->timeEnd = Carbon::today()->endOfMonth()->subMonth()->toDateString(); + $counters['payments']['lastMonth']->timeStart = $previousMonthStart->toDateString(); + $counters['payments']['lastMonth']->timeEnd = (clone $currentMonthStart)->subDay()->toDateString(); $counters['payments']->total = Payment::query()->count(); - - foreach($taxPayments->where('created_at', '>=', Carbon::today()->startOfYear()) as $taxPayment){ - $paymentCurrency = $taxPayment->currency_code; - if(!isset($counters['taxPayments']['thisYear'][$paymentCurrency])){ - - $counters['taxPayments']['thisYear']->put($paymentCurrency, collect()); - $counters['taxPayments']['thisYear'][$paymentCurrency]->total = 0; - $counters['taxPayments']['thisYear'][$paymentCurrency]->count = 0; - $counters['taxPayments']['thisYear'][$paymentCurrency]->price = 0; - $counters['taxPayments']['thisYear'][$paymentCurrency]->taxes = 0; - } - $counters['taxPayments']['thisYear'][$paymentCurrency]->total += $taxPayment->total_price; - $counters['taxPayments']['thisYear'][$paymentCurrency]->count++; - $counters['taxPayments']['thisYear'][$paymentCurrency]->price += $taxPayment->price; - $counters['taxPayments']['thisYear'][$paymentCurrency]->taxes += $taxPayment->tax_value; - } - - foreach($taxPayments->where('created_at', '>=', Carbon::today()->startOfYear()->subYear())->where('created_at', '<', Carbon::today()->startOfYear()) as $taxPayment){ - $paymentCurrency = $taxPayment->currency_code; - if(!isset($counters['taxPayments']['lastYear'][$paymentCurrency])){ - - $counters['taxPayments']['lastYear']->put($paymentCurrency, collect()); - $counters['taxPayments']['lastYear'][$paymentCurrency]->total = 0; - $counters['taxPayments']['lastYear'][$paymentCurrency]->count = 0; - $counters['taxPayments']['lastYear'][$paymentCurrency]->price = 0; - $counters['taxPayments']['lastYear'][$paymentCurrency]->taxes = 0; - } - $counters['taxPayments']['lastYear'][$paymentCurrency]->total += $taxPayment->total_price; - $counters['taxPayments']['lastYear'][$paymentCurrency]->count++; - $counters['taxPayments']['lastYear'][$paymentCurrency]->price += $taxPayment->price; - $counters['taxPayments']['lastYear'][$paymentCurrency]->taxes += $taxPayment->tax_value; - } - //sort currencies alphabetically and set some additional variables $counters['taxPayments']['thisYear'] = $counters['taxPayments']['thisYear']->sortKeys(); - $counters['taxPayments']['thisYear']->timeStart = Carbon::today()->startOfYear()->toDateString(); + $counters['taxPayments']['thisYear']->timeStart = $currentYearStart->toDateString(); $counters['taxPayments']['thisYear']->timeEnd = Carbon::today()->toDateString(); $counters['taxPayments']['lastYear'] = $counters['taxPayments']['lastYear']->sortKeys(); - $counters['taxPayments']['lastYear']->timeStart = Carbon::today()->startOfYear()->subYear()->toDateString(); - $counters['taxPayments']['lastYear']->timeEnd = Carbon::today()->endOfYear()->subYear()->toDateString(); + $counters['taxPayments']['lastYear']->timeStart = $previousYearStart->toDateString(); + $counters['taxPayments']['lastYear']->timeEnd = (clone $currentYearStart)->subDay()->toDateString(); $lastEgg = Egg::query()->latest('updated_at')->first(); $syncLastUpdate = $lastEgg ? $lastEgg->updated_at->isoFormat('LLL') : __('unknown'); //Get node information and prepare collection - $pteroNodeIds = []; - foreach ($this->pterodactyl->getNodes() as $pteroNode) { - array_push($pteroNodeIds, $pteroNode['attributes']['id']); - } + $pteroNodes = collect($this->pterodactyl->getNodes()) + ->map(fn ($node) => data_get($node, 'attributes')) + ->filter(fn ($node) => is_array($node) && isset($node['id'])) + ->keyBy('id'); + $pteroNodeIds = $pteroNodes->keys()->all(); $nodes = collect(); - foreach ($DBnodes = Node::query()->get() as $DBnode) { //gets all node information and prepares the structure + $DBnodes = Node::query()->get(); + foreach ($DBnodes as $DBnode) { //gets all node information and prepares the structure $nodeId = $DBnode['id']; - if (! in_array($nodeId, $pteroNodeIds)) { + if (! $pteroNodes->has($nodeId)) { continue; } //Check if node exists on pterodactyl too, if not, skip $nodes->put($nodeId, collect()); $nodes[$nodeId]->name = $DBnode['name']; - $pteroNode = $this->pterodactyl->getNode($nodeId); - $nodes[$nodeId]->usagePercent = round(max($pteroNode['allocated_resources']['memory'] / ($pteroNode['memory'] * ($pteroNode['memory_overallocate'] + 100) / 100), $pteroNode['allocated_resources']['disk'] / ($pteroNode['disk'] * ($pteroNode['disk_overallocate'] + 100) / 100)) * 100, 2); + $pteroNode = $pteroNodes->get($nodeId); + $nodes[$nodeId]->usagePercent = $this->calculateNodeUsagePercent($pteroNode); $counters['totalUsagePercent'] += $nodes[$nodeId]->usagePercent; $nodes[$nodeId]->totalServers = 0; @@ -170,35 +120,52 @@ public function index(GeneralSettings $general_settings, CurrencyHelper $currenc $nodes[$nodeId]->totalEarnings = 0; $nodes[$nodeId]->activeEarnings = 0; } - $counters['totalUsagePercent'] = ($DBnodes->count()) ? round($counters['totalUsagePercent'] / $DBnodes->count(), 2) : 0; - - foreach ($this->pterodactyl->getServers() as $server) { //gets all servers from Pterodactyl and calculates total of credit usage for each node separately + total - $nodeId = $server['attributes']['node']; - - if ($CPServer = Server::query()->where('pterodactyl_id', $server['attributes']['id'])->first()) { - $product = Product::query()->where('id', $CPServer->product_id)->first(); - $price = $product->getMonthlyPrice(); - if (! $CPServer->suspended) { - $counters['earnings']->active += $price; - $counters['servers']->active++; - $nodes[$nodeId]->activeEarnings += $price; - $nodes[$nodeId]->activeServers++; - } - $counters['earnings']->total += $price; - $counters['servers']->total++; - $nodes[$nodeId]->totalEarnings += $price; - $nodes[$nodeId]->totalServers++; + $counters['totalUsagePercent'] = ($nodes->count()) ? round($counters['totalUsagePercent'] / $nodes->count(), 2) : 0; + + $remoteServers = collect($this->pterodactyl->getServers()); + $cpServers = Server::query() + ->with('product') + ->whereIn('pterodactyl_id', $remoteServers->pluck('attributes.id')->filter()->all()) + ->get() + ->keyBy('pterodactyl_id'); + + foreach ($remoteServers as $server) { //gets all servers from Pterodactyl and calculates total of credit usage for each node separately + total + $serverAttributes = data_get($server, 'attributes'); + + if (! is_array($serverAttributes)) { + continue; } + + $nodeId = data_get($serverAttributes, 'node'); + $cpServer = $cpServers->get(data_get($serverAttributes, 'id')); + + if (! $cpServer || ! $cpServer->product || ! $nodes->has($nodeId)) { + continue; + } + + $price = $cpServer->product->getMonthlyPrice(); + + if (! $cpServer->suspended) { + $counters['earnings']->active += $price; + $counters['servers']->active++; + $nodes[$nodeId]->activeEarnings += $price; + $nodes[$nodeId]->activeServers++; + } + + $counters['earnings']->total += $price; + $counters['servers']->total++; + $nodes[$nodeId]->totalEarnings += $price; + $nodes[$nodeId]->totalServers++; } //Get latest tickets $tickets = collect(); - foreach (Ticket::query()->latest()->take(5)->get() as $ticket) { + foreach (Ticket::query()->with('user')->latest()->take(5)->get() as $ticket) { $tickets->put($ticket->ticket_id, collect()); $tickets[$ticket->ticket_id]->title = $ticket->title; - $user = User::query()->where('id', $ticket->user_id)->first(); - $tickets[$ticket->ticket_id]->user_id = $user->id; - $tickets[$ticket->ticket_id]->user = $user->name; + $user = $ticket->user; + $tickets[$ticket->ticket_id]->user_id = $user?->id; + $tickets[$ticket->ticket_id]->user = $user?->name ?? __('Deleted user'); $tickets[$ticket->ticket_id]->status = $ticket->status; $tickets[$ticket->ticket_id]->last_updated = $ticket->updated_at->diffForHumans(); switch ($ticket->status) { @@ -222,7 +189,7 @@ public function index(GeneralSettings $general_settings, CurrencyHelper $currenc 'nodes' => $nodes, 'syncLastUpdate' => $syncLastUpdate, 'deletedNodesPresent' => ($DBnodes->count() != count($pteroNodeIds)) ? true : false, - 'perPageLimit' => ($counters['servers']->total != Server::query()->count()) ? true : false, + 'perPageLimit' => ($remoteServers->count() != Server::query()->count()) ? true : false, 'tickets' => $tickets, 'credits_display_name' => $general_settings->credits_display_name ]); @@ -240,4 +207,65 @@ public function syncPterodactyl() return redirect()->back()->with('success', __('Pterodactyl synced')); } + + private function paymentSummary(Carbon $from, Carbon $to): Collection + { + $summary = collect(); + + $rows = Payment::query() + ->selectRaw('currency_code, SUM(total_price) as total, COUNT(*) as payment_count') + ->where('status', 'paid') + ->where('created_at', '>=', $from) + ->where('created_at', '<', $to) + ->groupBy('currency_code') + ->orderBy('currency_code') + ->get(); + + foreach ($rows as $row) { + $summary->put($row->currency_code, collect([ + 'total' => (int) $row->total, + 'count' => (int) $row->payment_count, + ])); + } + + return $summary; + } + + private function taxPaymentSummary(Carbon $from, Carbon $to): Collection + { + $summary = collect(); + + $rows = Payment::query() + ->selectRaw('currency_code, SUM(total_price) as total, COUNT(*) as payment_count, SUM(price) as price_total, SUM(tax_value) as tax_total') + ->where('status', 'paid') + ->where('created_at', '>=', $from) + ->where('created_at', '<', $to) + ->groupBy('currency_code') + ->orderBy('currency_code') + ->get(); + + foreach ($rows as $row) { + $summary->put($row->currency_code, collect([ + 'total' => (int) $row->total, + 'count' => (int) $row->payment_count, + 'price' => (int) $row->price_total, + 'taxes' => (int) $row->tax_total, + ])); + } + + return $summary; + } + + private function calculateNodeUsagePercent(array $node): float + { + $memoryCapacity = (float) data_get($node, 'memory', 0) * (((float) data_get($node, 'memory_overallocate', 0) + 100) / 100); + $diskCapacity = (float) data_get($node, 'disk', 0) * (((float) data_get($node, 'disk_overallocate', 0) + 100) / 100); + $allocatedMemory = (float) data_get($node, 'allocated_resources.memory', 0); + $allocatedDisk = (float) data_get($node, 'allocated_resources.disk', 0); + + $memoryUsage = $memoryCapacity > 0 ? ($allocatedMemory / $memoryCapacity) : 0; + $diskUsage = $diskCapacity > 0 ? ($allocatedDisk / $diskCapacity) : 0; + + return round(max($memoryUsage, $diskUsage) * 100, 2); + } } diff --git a/app/Http/Controllers/Admin/PartnerController.php b/app/Http/Controllers/Admin/PartnerController.php index fdcf901c9..f4d85cce2 100644 --- a/app/Http/Controllers/Admin/PartnerController.php +++ b/app/Http/Controllers/Admin/PartnerController.php @@ -8,6 +8,7 @@ use App\Settings\LocaleSettings; use App\Settings\ReferralSettings; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; class PartnerController extends Controller { @@ -33,7 +34,7 @@ public function create() return view('admin.partners.create', [ 'partners' => PartnerDiscount::get(), - 'users' => User::orderBy('name')->get(), + 'users' => User::query()->orderBy('name')->get(['id', 'name']), ]); } @@ -47,17 +48,14 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ - 'user_id' => 'required|integer|min:0', + $validated = $request->validate([ + 'user_id' => ['required', 'integer', 'exists:users,id', Rule::unique('partner_discounts', 'user_id')], 'partner_discount' => 'required|integer|max:100|min:0', 'registered_user_discount' => 'required|integer|max:100|min:0', + 'referral_system_commission' => 'nullable|integer|min:-1|max:100', ]); - if(PartnerDiscount::where("user_id",$request->user_id)->exists()){ - return redirect()->route('admin.partners.index')->with('error', __('Partner already exists')); - } - - PartnerDiscount::create($request->all()); + PartnerDiscount::create($validated); return redirect()->route('admin.partners.index')->with('success', __('partner has been created!')); } @@ -75,7 +73,7 @@ public function edit(PartnerDiscount $partner) return view('admin.partners.edit', [ 'partners' => PartnerDiscount::get(), 'partner' => $partner, - 'users' => User::orderBy('name')->get(), + 'users' => User::query()->orderBy('name')->get(['id', 'name']), ]); } @@ -90,14 +88,14 @@ public function update(Request $request, PartnerDiscount $partner) { $this->checkPermission(self::WRITE_PERMISSION); - //dd($request); - $request->validate([ - 'user_id' => 'required|integer|min:0', + $validated = $request->validate([ + 'user_id' => ['required', 'integer', 'exists:users,id', Rule::unique('partner_discounts', 'user_id')->ignore($partner->id)], 'partner_discount' => 'required|integer|max:100|min:0', 'registered_user_discount' => 'required|integer|max:100|min:0', + 'referral_system_commission' => 'nullable|integer|min:-1|max:100', ]); - $partner->update($request->all()); + $partner->update($validated); return redirect()->route('admin.partners.index')->with('success', __('partner has been updated!')); } @@ -121,9 +119,9 @@ public function destroy(PartnerDiscount $partner) public function dataTable() { - $this->checkAnyPermission([self::WRITE_PERMISSION,self::READ_PERMISSION]); + $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION]); - $query = PartnerDiscount::query(); + $query = PartnerDiscount::query()->with('user'); return datatables($query) ->addColumn('actions', function (PartnerDiscount $partner) { @@ -137,7 +135,9 @@ public function dataTable() '; }) ->addColumn('user', function (PartnerDiscount $partner) { - return ($user = User::where('id', $partner->user_id)->first()) ? '' . $user->name . '' : __('Unknown user'); + return $partner->user + ? '' . e($partner->user->name) . '' + : __('Unknown user'); }) ->editColumn('created_at', function (PartnerDiscount $partner) { return $partner->created_at ? $partner->created_at->diffForHumans() : ''; diff --git a/app/Http/Controllers/Admin/PaymentController.php b/app/Http/Controllers/Admin/PaymentController.php index 8c5916ee8..43037d97a 100644 --- a/app/Http/Controllers/Admin/PaymentController.php +++ b/app/Http/Controllers/Admin/PaymentController.php @@ -28,6 +28,8 @@ use App\Settings\LocaleSettings; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; class PaymentController extends Controller { @@ -57,23 +59,14 @@ public function index(LocaleSettings $locale_settings) */ public function checkOut(ShopProduct $shopProduct, GeneralSettings $general_settings, CouponSettings $coupon_settings, CurrencyHelper $currencyHelper) { - $this->checkPermission(self::BUY_PERMISSION); + $this->ensureStoreCheckoutAllowed($shopProduct); $discount = PartnerDiscount::getDiscount(); $price = $shopProduct->price - ($shopProduct->price * $discount / 100); $paymentGateways = []; if ($price > 0) { - $extensions = ExtensionHelper::getAllExtensionsByNamespace('PaymentGateways'); - - // build a paymentgateways array that contains the routes for the payment gateways and the image path for the payment gateway which lays in public/images/Extensions/PaymentGateways with the extensionname in lowercase - foreach ($extensions as $extension) { - $extensionName = basename($extension); - - $extensionSettings = ExtensionHelper::getExtensionSettings($extensionName); - if ($extensionSettings->enabled == false) continue; - - + foreach (array_keys($this->enabledPaymentGatewayExtensions()) as $extensionName) { $payment = new \stdClass(); $payment->name = ExtensionHelper::getExtensionConfig($extensionName, 'name'); $payment->image = asset('images/Extensions/PaymentGateways/' . strtolower($extensionName) . '_logo.png'); @@ -101,23 +94,31 @@ public function checkOut(ShopProduct $shopProduct, GeneralSettings $general_sett * @param ShopProduct $shopProduct * @return RedirectResponse */ - public function handleFreeProduct(ShopProduct $shopProduct) + public function handleFreeProduct(ShopProduct $shopProduct, ?float $subtotal = null) { /** @var User $user */ $user = Auth::user(); - //create a payment + $this->ensureStoreCheckoutAllowed($shopProduct); + + $chargedAmount = $subtotal ?? $shopProduct->getTotalPrice(); + + if ($chargedAmount > 0) { + abort(403); + } + + // create a payment for a free checkout $payment = Payment::create([ 'user_id' => $user->id, - 'payment_id' => uniqid(), + 'payment_id' => (string) \Illuminate\Support\Str::ulid(), 'payment_method' => 'free', 'type' => $shopProduct->type, 'status' => PaymentStatus::PAID, 'amount' => $shopProduct->quantity, - 'price' => $shopProduct->price - ($shopProduct->price * PartnerDiscount::getDiscount() / 100), - 'tax_value' => $shopProduct->getTaxValue(), - 'tax_percent' => $shopProduct->getTaxPercent(), - 'total_price' => $shopProduct->getTotalPrice(), + 'price' => 0, + 'tax_value' => 0, + 'tax_percent' => 0, + 'total_price' => 0, 'currency_code' => $shopProduct->currency_code, 'shop_item_product_id' => $shopProduct->id, ]); @@ -134,13 +135,21 @@ public function handleFreeProduct(ShopProduct $shopProduct) public function pay(Request $request) { try { + $this->checkPermission(self::BUY_PERMISSION); + + $data = $request->validate([ + 'product_id' => 'required|exists:shop_products,id', + 'payment_method' => 'nullable|string|max:191', + 'coupon_code' => 'nullable|string|max:191', + ]); + $user = Auth::user(); $user = User::findOrFail($user->id); - $productId = $request->product_id; - $shopProduct = ShopProduct::findOrFail($productId); + $shopProduct = ShopProduct::findOrFail($data['product_id']); + $this->ensureStoreCheckoutAllowed($shopProduct); - $paymentGateway = $request->payment_method; - $couponCode = $request->coupon_code; + $paymentGateway = $data['payment_method'] ?? null; + $couponCode = $data['coupon_code'] ?? null; $subtotal = $shopProduct->getTotalPrice(); @@ -154,7 +163,14 @@ public function pay(Request $request) } if ($subtotal <= 0) { - return $this->handleFreeProduct($shopProduct); + return $this->handleFreeProduct($shopProduct, $subtotal); + } + + $enabledPaymentGateways = $this->enabledPaymentGatewayExtensions(); + if ($paymentGateway === null || !isset($enabledPaymentGateways[$paymentGateway])) { + throw ValidationException::withMessages([ + 'payment_method' => __('Please select a valid payment method.'), + ]); } // create a new payment @@ -173,9 +189,13 @@ public function pay(Request $request) 'shop_item_product_id' => $shopProduct->id, ]); - $paymentGatewayExtension = ExtensionHelper::getExtensionClass($paymentGateway); + $paymentGatewayExtension = $enabledPaymentGateways[$paymentGateway]; $redirectUrl = $paymentGatewayExtension::getRedirectUrl($payment, $shopProduct, $subtotal); + } catch (ValidationException $e) { + throw $e; + } catch (HttpExceptionInterface $e) { + throw $e; } catch (Exception $e) { Log::error($e->getMessage()); return redirect()->route('store.index')->with('error', __('Oops, something went wrong! Please try again later.')); @@ -206,7 +226,7 @@ public function dataTable() return datatables($query) ->addColumn('user', function (Payment $payment) { - return ($payment->user) ? '' . $payment->user->name . '' : __('Unknown user'); + return ($payment->user) ? '' . e($payment->user->name) . '' : __('Unknown user'); }) ->editColumn('amount', function (Payment $payment, CurrencyHelper $currencyHelper) { return $payment->type == 'Credits' ? $currencyHelper->formatForDisplay($payment->amount) : $payment->amount; @@ -241,4 +261,39 @@ public function dataTable() ->rawColumns(['actions', 'user']) ->make(true); } + + private function ensureStoreCheckoutAllowed(ShopProduct $shopProduct): void + { + $this->checkPermission(self::BUY_PERMISSION); + + if (! app(GeneralSettings::class)->store_enabled || $shopProduct->disabled) { + abort(404); + } + } + + /** + * @return array + */ + private function enabledPaymentGatewayExtensions(): array + { + $gateways = []; + + foreach (ExtensionHelper::getAllExtensionsByNamespace('PaymentGateways') as $extension) { + $extensionName = basename($extension); + $extensionSettings = ExtensionHelper::getExtensionSettings($extensionName); + + if (! $extensionSettings?->enabled) { + continue; + } + + $extensionClass = ExtensionHelper::getExtensionClass($extensionName); + if ($extensionClass === null) { + continue; + } + + $gateways[$extensionName] = $extensionClass; + } + + return $gateways; + } } diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index a1f00d171..22450d072 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -11,6 +11,7 @@ use App\Settings\GeneralSettings; use App\Settings\LocaleSettings; use Exception; +use Illuminate\Support\Facades\DB; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -78,7 +79,7 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'name' => 'required|max:30', 'price' => 'required|numeric|max:1000000|min:0', 'memory' => 'required|numeric|max:1000000|min:0', @@ -92,7 +93,9 @@ public function store(Request $request) 'databases' => 'required|numeric|max:1000000|min:0', 'backups' => 'required|numeric|max:1000000|min:0', 'allocations' => 'required|numeric|max:1000000|min:0', + 'nodes' => 'required|array|min:1', 'nodes.*' => 'required|exists:nodes,id', + 'eggs' => 'required|array|min:1', 'eggs.*' => 'required|exists:eggs,id', 'disabled' => 'nullable', 'oom_killer' => 'nullable', @@ -103,11 +106,11 @@ public function store(Request $request) $disabled = ! is_null($request->input('disabled')); $oomkiller = ! is_null($request->input('oom_killer')); - $product = Product::create(array_merge($request->all(), ['disabled' => $disabled, 'oom_killer' => $oomkiller])); + $product = Product::create($this->productPayload($validated, $disabled, $oomkiller)); //link nodes and eggs - $product->eggs()->attach($request->input('eggs')); - $product->nodes()->attach($request->input('nodes')); + $product->eggs()->attach($validated['eggs']); + $product->nodes()->attach($validated['nodes']); return redirect()->route('admin.products.index')->with('success', __('Product has been created!')); } @@ -120,7 +123,7 @@ public function store(Request $request) */ public function show(Product $product, GeneralSettings $general_settings) { - $this->checkAnyPermission([self::READ_PERMISSION,self::WRITE_PERMISSION]); + $this->checkAnyPermission([self::READ_PERMISSION, self::EDIT_PERMISSION, self::DELETE_PERMISSION]); return view('admin.products.show', [ 'product' => $product, @@ -157,7 +160,7 @@ public function update(Request $request, Product $product): RedirectResponse { $this->checkPermission(self::EDIT_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'name' => 'required|max:30', 'price' => 'required|numeric|max:1000000|min:0', 'memory' => 'required|numeric|max:1000000|min:0', @@ -171,7 +174,9 @@ public function update(Request $request, Product $product): RedirectResponse 'serverlimit' => 'required|numeric|max:1000000|min:0', 'backups' => 'required|numeric|max:1000000|min:0', 'allocations' => 'required|numeric|max:1000000|min:0', + 'nodes' => 'required|array|min:1', 'nodes.*' => 'required|exists:nodes,id', + 'eggs' => 'required|array|min:1', 'eggs.*' => 'required|exists:eggs,id', 'disabled' => 'nullable', 'oom_killer' => 'nullable', @@ -181,13 +186,12 @@ public function update(Request $request, Product $product): RedirectResponse $disabled = ! is_null($request->input('disabled')); $oomkiller = ! is_null($request->input('oom_killer')); - $product->update(array_merge($request->all(), ['disabled' => $disabled, 'oom_killer' => $oomkiller])); + DB::transaction(function () use ($product, $validated, $disabled, $oomkiller) { + $product->update($this->productPayload($validated, $disabled, $oomkiller)); - //link nodes and eggs - $product->eggs()->detach(); - $product->nodes()->detach(); - $product->eggs()->attach($request->input('eggs')); - $product->nodes()->attach($request->input('nodes')); + $product->eggs()->sync($validated['eggs']); + $product->nodes()->sync($validated['nodes']); + }); return redirect()->route('admin.products.index')->with('success', __('Product has been updated!')); } @@ -199,7 +203,7 @@ public function update(Request $request, Product $product): RedirectResponse */ public function disable(Product $product) { - $this->checkPermission(self::WRITE_PERMISSION); + $this->checkPermission(self::EDIT_PERMISSION); $product->update(['disabled' => ! $product->disabled]); @@ -233,23 +237,37 @@ public function destroy(Product $product) */ public function dataTable() { - $this->checkPermission(self::READ_PERMISSION); + $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION, self::EDIT_PERMISSION, self::DELETE_PERMISSION]); $query = Product::with(['servers']); return datatables($query) ->addColumn('actions', function (Product $product) { - return ' - - - - - - '.csrf_field().' - '.method_field('DELETE').' - - - '; + $actions = []; + + if (auth()->user()?->can(self::READ_PERMISSION)) { + $actions[] = ''; + } + + if (auth()->user()?->can(self::WRITE_PERMISSION)) { + $actions[] = ''; + } + + if (auth()->user()?->can(self::EDIT_PERMISSION)) { + $actions[] = ''; + } + + if (auth()->user()?->can(self::DELETE_PERMISSION)) { + $actions[] = ' +
+ '.csrf_field().' + '.method_field('DELETE').' + +
+ '; + } + + return implode('', $actions); }) ->addColumn('servers', function (Product $product) { @@ -262,6 +280,10 @@ public function dataTable() return $product->eggs()->count(); }) ->editColumn('disabled', function (Product $product) { + if (! auth()->user()?->can(self::EDIT_PERMISSION)) { + return $product->disabled ? __('disabled') : __('enabled'); + } + $checked = $product->disabled == false ? 'checked' : ''; return ' @@ -336,4 +358,27 @@ private function getSwapValidator(): callable } }; } + + private function productPayload(array $validated, bool $disabled, bool $oomkiller): array + { + return [ + 'name' => $validated['name'], + 'price' => $validated['price'], + 'memory' => $validated['memory'], + 'cpu' => $validated['cpu'], + 'swap' => $validated['swap'], + 'description' => $validated['description'], + 'disk' => $validated['disk'], + 'minimum_credits' => $validated['minimum_credits'] ?? null, + 'io' => $validated['io'], + 'serverlimit' => $validated['serverlimit'], + 'databases' => $validated['databases'], + 'backups' => $validated['backups'], + 'allocations' => $validated['allocations'], + 'disabled' => $disabled, + 'oom_killer' => $oomkiller, + 'billing_period' => $validated['billing_period'], + 'default_billing_priority' => $validated['default_billing_priority'], + ]; + } } diff --git a/app/Http/Controllers/Admin/RoleController.php b/app/Http/Controllers/Admin/RoleController.php index 8cb42e7b3..86c4cb024 100644 --- a/app/Http/Controllers/Admin/RoleController.php +++ b/app/Http/Controllers/Admin/RoleController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Admin; +use App\Constants\Roles; use App\Http\Controllers\Controller; use App\Models\User; use Exception; +use Illuminate\Validation\Rule; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -36,7 +38,7 @@ public function index(Request $request) //datatables if ($request->ajax()) { - return $this->dataTableQuery(); + return $this->dataTable(); } $html = $this->dataTable(); @@ -66,14 +68,23 @@ public function store(Request $request): RedirectResponse { $this->checkPermission(self::CREATE_PERMISSION); + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:191', Rule::unique('roles', 'name')], + 'color' => ['required', 'string', 'max:20'], + 'power' => ['required', 'integer', 'min:0', 'max:' . max(0, $this->currentUserPower() - 1)], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => ['integer', 'exists:permissions,id'], + ]); + $role = Role::create([ - 'name' => $request->name, - 'color' => $request->color, - 'power' => $request->power + 'name' => $validated['name'], + 'color' => $validated['color'], + 'power' => $validated['power'], + 'guard_name' => 'web', ]); - if ($request->permissions) { - $collectedPermissions = collect($request->permissions)->map(fn($val)=>(int)$val); + if (! empty($validated['permissions'])) { + $collectedPermissions = collect($validated['permissions'])->map(fn ($val) => (int) $val); $role->givePermissionTo($collectedPermissions); } @@ -100,7 +111,7 @@ public function edit(Role $role) { $this->checkPermission(self::EDIT_PERMISSION); - if(Auth::user()->roles[0]->power < $role->power){ + if ($this->currentUserPower() < $role->power) { return back()->with("error","You dont have enough Power to edit that Role"); } @@ -119,38 +130,36 @@ public function update(Request $request, Role $role) { $this->checkPermission(self::EDIT_PERMISSION); - if(Auth::user()->roles[0]->power < $role->power){ + if ($this->currentUserPower() < $role->power) { return back()->with("error","You dont have enough Power to edit that Role"); } - if ($request->permissions) { - if($role->id != 1){ //disable admin permissions change - $collectedPermissions = collect($request->permissions)->map(fn($val)=>(int)$val); + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:191', Rule::unique('roles', 'name')->ignore($role->id)], + 'color' => ['required', 'string', 'max:20'], + 'power' => ['required', 'integer', 'min:0', 'max:' . max(0, $this->currentUserPower() - 1)], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => ['integer', 'exists:permissions,id'], + ]); + + if (! empty($validated['permissions'])) { + if (! $this->isAdminRole($role)) { + $collectedPermissions = collect($validated['permissions'])->map(fn ($val) => (int) $val); $role->syncPermissions($collectedPermissions); } + } elseif (! $this->isAdminRole($role)) { + $role->syncPermissions([]); } - //if($role->id == 1 || $role->id == 3 || $role->id == 4){ //dont let the user change the names of these roles - // $role->update([ - // 'color' => $request->color - // ]); - //}else{ - $role->update([ - 'name' => $request->name, - 'color' => $request->color, - 'power' => $request->power - ]); - //} - - //if($role->id == 1){ - // return redirect()->route('admin.roles.index')->with('success', __('Role updated. Name and Permissions of this Role cannot be changed')); - //}elseif($role->id == 4 || $role->id == 3){ - // return redirect()->route('admin.roles.index')->with('success', __('Role updated. Name of this Role cannot be changed')); - // }else{ - return redirect() - ->route('admin.roles.index') - ->with('success', __('Role saved')); - //} + $role->update([ + 'name' => $validated['name'], + 'color' => $validated['color'], + 'power' => $validated['power'], + ]); + + return redirect() + ->route('admin.roles.index') + ->with('success', __('Role saved')); } /** @@ -162,15 +171,22 @@ public function destroy(Role $role) { $this->checkPermission(self::DELETE_PERMISSION); - if($role->id == 1 || $role->id == 3 || $role->id == 4){ //cannot delete the hard coded roles + if (! $role->isDeletable()) { return back()->with("error","You cannot delete that role"); } + $defaultRole = Role::query() + ->find(Roles::USER_ROLE_ID) + ?? Role::query()->where('name', 'User')->first(); + + if (! $defaultRole) { + return back()->with('error', __('Default user role could not be found.')); + } + $users = User::role($role)->get(); - foreach($users as $user){ - //$user->syncRoles(['Member']); - $user->syncRoles(4); + foreach ($users as $user) { + $user->syncRoles([$defaultRole]); } $role->delete(); @@ -186,7 +202,8 @@ public function destroy(Role $role) */ public function dataTable() { - $this->checkPermission(self::READ_PERMISSION); + $allConstants = (new \ReflectionClass(__CLASS__))->getConstants(); + $this->checkAnyPermission($allConstants); $query = Role::query()->withCount(['users', 'permissions'])->get(); @@ -195,16 +212,25 @@ public function dataTable() return $role->id; }) ->addColumn('actions', function (Role $role) { - return ' - -
- ' . csrf_field() . ' - ' . method_field("DELETE") . ' - -
- '; + $actions = []; + + if (auth()->user()?->can(self::EDIT_PERMISSION) && $this->currentUserPower() >= $role->power) { + $actions[] = ' + + '; + } + + if (auth()->user()?->can(self::DELETE_PERMISSION) && $role->isDeletable()) { + $actions[] = ' +
+ ' . csrf_field() . ' + ' . method_field("DELETE") . ' + +
+ '; + } + + return implode('', $actions); }) ->editColumn('name', function (Role $role) { @@ -223,4 +249,14 @@ class="fa fas fa-trash"> ->rawColumns(['actions', 'name']) ->make(true); } + + private function currentUserPower(): int + { + return (int) (Auth::user()?->roles()->max('power') ?? 0); + } + + private function isAdminRole(Role $role): bool + { + return $role->id === Roles::ADMIN_ROLE_ID || $role->name === 'Admin'; + } } diff --git a/app/Http/Controllers/Admin/ServerController.php b/app/Http/Controllers/Admin/ServerController.php index f254c17b5..4de5e79e7 100644 --- a/app/Http/Controllers/Admin/ServerController.php +++ b/app/Http/Controllers/Admin/ServerController.php @@ -96,8 +96,8 @@ public function update(Request $request, Server $server, DiscordSettings $discor ]); $request->validate([ - 'identifier' => 'required|string', - 'user_id' => 'required|integer', + 'identifier' => ['required', 'string', 'max:191'], + 'user_id' => ['required', 'integer', 'exists:users,id'], ]); @@ -118,13 +118,13 @@ public function update(Request $request, Server $server, DiscordSettings $discor // remove the role from the old owner $oldOwner = User::findOrFail($server->user_id); $discordUser = $oldOwner->discordUser; - if ($discordUser && $oldOwner->servers->count() <= 1) { + if ($discordUser && ! $oldOwner->activeServers()->where('servers.id', '!=', $server->id)->exists()) { $discordUser->addOrRemoveRole('remove', $discord_settings->role_id_for_active_clients); } // add the role to the new owner $discordUser = $user->discordUser; - if ($discordUser && $user->servers->count() >= 1) { + if ($discordUser && is_null($server->canceled) && is_null($server->suspended)) { $discordUser->addOrRemoveRole('add', $discord_settings->role_id_for_active_clients); } } @@ -165,7 +165,7 @@ public function destroy(Server $server, DiscordSettings $discord_settings) if($discord_settings->role_for_active_clients) { $user = User::findOrFail($server->user_id); $discordUser = $user->discordUser; - if($discordUser && $user->servers->count() <= 1) { + if($discordUser && ! $user->activeServers()->where('servers.id', '!=', $server->id)->exists()) { $discordUser->addOrRemoveRole('remove', $discord_settings->role_id_for_active_clients); } } @@ -178,7 +178,12 @@ public function destroy(Server $server, DiscordSettings $discord_settings) return redirect()->route('admin.servers.index')->with('success', __('Server removed')); } catch (Exception $e) { - return redirect()->route('admin.servers.index')->with('error', __('An exception has occurred while trying to remove a resource "') . $e->getMessage() . '"'); + Log::warning('Failed to delete server from admin area.', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->route('admin.servers.index')->with('error', __('An exception has occurred while trying to remove this server.')); } } @@ -195,9 +200,14 @@ public function cancel(Server $server) $server->update([ 'canceled' => now(), ]); - return redirect()->route('servers.index')->with('success', __('Server canceled')); + return redirect()->route('admin.servers.index')->with('success', __('Server canceled')); } catch (Exception $e) { - return redirect()->route('servers.index')->with('error', __('An exception has occurred while trying to cancel the server"') . $e->getMessage() . '"'); + Log::warning('Failed to cancel server from admin area.', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return redirect()->route('admin.servers.index')->with('error', __('An exception has occurred while trying to cancel the server.')); } } @@ -294,9 +304,6 @@ public function dataTable(Request $request) } $query->select('servers.*'); - Log::info($request->input('order')); - - return datatables($query) ->addColumn('user', function (Server $server) { if ($server->user) { @@ -312,11 +319,18 @@ public function dataTable(Request $request) $suspendColor = $server->isSuspended() ? 'btn-success' : 'btn-warning'; $suspendIcon = $server->isSuspended() ? 'fa-play-circle' : 'fa-pause-circle'; $suspendText = $server->isSuspended() ? __('Unsuspend') : __('Suspend'); + $actions = []; + + if (auth()->user()?->can(self::WRITE_PERMISSION) + || auth()->user()?->can(self::CHANGEOWNER_PERMISSION) + || auth()->user()?->can(self::CHANGE_IDENTIFIER_PERMISSION)) { + $actions[] = ''; + } - return ' - + if (auth()->user()?->can(self::SUSPEND_PERMISSION)) { + $actions[] = '
- ' . csrf_field() . ' + ' . csrf_field() . ' -
+ + '; + } -
- ' . csrf_field() . ' - ' . method_field('DELETE') . ' - -
+ if (auth()->user()?->can(self::DELETE_PERMISSION)) { + $actions[] = ' +
+ ' . csrf_field() . ' + ' . method_field('DELETE') . ' + +
+ '; + } - '; + return implode('', $actions); }) ->addColumn('status', function (Server $server) { $labelColor = $server->suspended ? 'text-danger' : 'text-success'; @@ -349,10 +369,10 @@ class="btn btn-sm '.$suspendColor.' text-white mr-1 suspend-btn" return $server->suspended ? $server->suspended->diffForHumans() : ''; }) ->editColumn('name', function (Server $server, PterodactylSettings $ptero_settings) { - $url = e(rtrim($ptero_settings->panel_url, '/')); - $pteroId = (int) $server->pterodactyl_id; + $url = e(rtrim($ptero_settings->panel_url, '/')); + $pteroId = (int) $server->pterodactyl_id; - return '' . e($server->name) . ''; + return '' . e($server->name) . ''; }) ->rawColumns(['user', 'actions', 'status', 'name']) ->make(); diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 77b8ba4ec..50ae537ad 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -5,6 +5,7 @@ use App\Facades\Currency; use App\Helpers\ExtensionHelper; use App\Http\Controllers\Controller; +use App\Support\HtmlSanitizer; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; @@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Storage; use Qirolab\Theme\Theme; +use ReflectionProperty; use Spatie\LaravelSettings\Settings; class SettingsController extends Controller @@ -61,6 +63,12 @@ private function getSettingsCategoryClassMap(): array */ public function index() { + if (! $this->canManageSettings()) { + abort(403, __('User does not have the right permissions.')); + } + + // get all other settings in app/Settings directory + // group items by file name like $categories $settings = collect(); $settingsFiles = $this->getAvailableSettingsClasses(); @@ -89,7 +97,12 @@ public function index() 'identifier' => $optionInputData[$key]['identifier'] ?? 'option' ]; - if($optionInputData[$key]['type'] === 'number') { + if (($optionInputData[$key]['type'] ?? null) === 'password') { + $optionsData[$key]['configured'] = ! empty($value); + $optionsData[$key]['value'] = ''; + } + + if (($optionInputData[$key]['type'] ?? null) === 'number') { $optionsData[$key]['step'] = $optionInputData[$key]['step'] ?? '1'; if ($optionInputData[$key]['mustBeConverted'] ?? false) { @@ -111,7 +124,13 @@ public function index() $optionsData['settings_class'] = $className; - $settings[str_replace('Settings', '', class_basename($className))] = $optionsData; + $category = str_replace('Settings', '', class_basename($className)); + + if (! $this->canViewSettingsCategory($category)) { + continue; + } + + $settings[$category] = $optionsData; } $settings = $settings->sortBy('position'); @@ -149,15 +168,11 @@ public function update(Request $request) $category = strtolower((string) $request->input('category')); $settingsClassMap = $this->getSettingsCategoryClassMap(); - if (!isset($settingsClassMap[$category])) { + if (! isset($settingsClassMap[$category])) { abort(400, 'Invalid settings category.'); } $resolvedSettingsClass = $settingsClassMap[$category]; - $requestedSettingsClass = (string) $request->input('settings_class'); - if ($requestedSettingsClass !== $resolvedSettingsClass) { - abort(400, 'Invalid settings class.'); - } $this->checkPermission("settings." . $category . ".write"); @@ -175,9 +190,13 @@ public function update(Request $request) $settingsClass = new $resolvedSettingsClass(); + $optionInputData = method_exists($resolvedSettingsClass, 'getOptionInputData') + ? $resolvedSettingsClass::getOptionInputData() + : []; + foreach ($settingsClass->toArray() as $key => $value) { // Get the type of the settingsclass property - $rp = new \ReflectionProperty($settingsClass, $key); + $rp = new ReflectionProperty($settingsClass, $key); $rpType = $rp->getType(); if ($rpType && $rpType->getName() === 'bool') { @@ -185,11 +204,16 @@ public function update(Request $request) continue; } if ($rp->name == 'available') { - $settingsClass->$key = implode(",", $request->$key); + $settingsClass->$key = implode(",", (array) $request->input($key, [])); continue; } $inputValue = $request->input($key); + $fieldType = $optionInputData[$key]['type'] ?? null; + + if ($fieldType === 'password' && ($inputValue === null || $inputValue === '')) { + continue; + } // User/referral currency values are stored in thousandths. $currencyKeys = [ @@ -203,6 +227,10 @@ public function update(Request $request) $inputValue = Currency::prepareForDatabase($inputValue); } + if ($this->shouldSanitizeRichText($resolvedSettingsClass, $key)) { + $inputValue = HtmlSanitizer::sanitizeRichText($inputValue); + } + $nullable = $rpType ? $rpType->allowsNull() : true; if ($nullable) { $settingsClass->$key = $inputValue ?? null; @@ -242,4 +270,47 @@ public function updateIcons(Request $request) return Redirect::to('admin/settings#icons')->with('success', 'Icons updated successfully.'); } + + private function canManageSettings(): bool + { + $user = request()->user(); + + if (! $user) { + return false; + } + + if ($user->can('*') || $user->can(self::ICON_PERMISSION)) { + return true; + } + + return $user->getAllPermissions() + ->pluck('name') + ->contains(fn (string $permission) => str_starts_with($permission, 'settings.')); + } + + private function canViewSettingsCategory(string $category): bool + { + $user = request()->user(); + + if (! $user) { + return false; + } + + $permission = 'settings.' . strtolower($category) . '.write'; + + return $user->can('*') || $user->can($permission); + } + + private function shouldSanitizeRichText(string $settingsClass, string $key): bool + { + return in_array([$settingsClass, $key], [ + ['App\\Settings\\GeneralSettings', 'alert_message'], + ['App\\Settings\\WebsiteSettings', 'motd_message'], + ['App\\Settings\\TicketSettings', 'information'], + ['App\\Settings\\TermsSettings', 'terms_of_service'], + ['App\\Settings\\TermsSettings', 'privacy_policy'], + ['App\\Settings\\TermsSettings', 'imprint'], + ['App\\Settings\\InvoiceSettings', 'additional_notes'], + ], true); + } } diff --git a/app/Http/Controllers/Admin/ShopProductController.php b/app/Http/Controllers/Admin/ShopProductController.php index db5782d30..2f1a4944a 100644 --- a/app/Http/Controllers/Admin/ShopProductController.php +++ b/app/Http/Controllers/Admin/ShopProductController.php @@ -65,7 +65,7 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'disabled' => 'nullable', 'type' => 'required|string', 'currency_code' => ['required', 'string', 'max:3', Rule::in(config('currency_codes'))], @@ -75,8 +75,8 @@ public function store(Request $request) 'display' => 'required|string|max:60', ]); - $disabled = !is_null($request->input('disabled')); - ShopProduct::create(array_merge($request->all(), ['disabled' => $disabled])); + $disabled = $request->boolean('disabled'); + ShopProduct::create(array_merge($validated, ['disabled' => $disabled])); return redirect()->route('admin.store.index')->with('success', __('Store item has been created!')); } @@ -109,7 +109,7 @@ public function update(Request $request, ShopProduct $shopProduct) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'disabled' => 'nullable', 'type' => 'required|string', 'currency_code' => ['required', 'string', 'max:3', Rule::in(config('currency_codes'))], @@ -119,8 +119,8 @@ public function update(Request $request, ShopProduct $shopProduct) 'display' => 'required|string|max:60', ]); - $disabled = !is_null($request->input('disabled')); - $shopProduct->update(array_merge($request->all(), ['disabled' => $disabled])); + $disabled = $request->boolean('disabled'); + $shopProduct->update(array_merge($validated, ['disabled' => $disabled])); return redirect()->route('admin.store.index')->with('success', __('Store item has been updated!')); } @@ -162,17 +162,26 @@ public function dataTable(Request $request) return datatables($query) ->addColumn('actions', function (ShopProduct $shopProduct) { - return ' - - -
- ' . csrf_field() . ' - ' . method_field('DELETE') . ' - -
- '; + $actions = []; + + if (auth()->user()?->can(self::WRITE_PERMISSION)) { + $actions[] = ''; + $actions[] = ' +
+ ' . csrf_field() . ' + ' . method_field('DELETE') . ' + +
+ '; + } + + return implode('', $actions); }) ->editColumn('disabled', function (ShopProduct $shopProduct) { + if (! auth()->user()?->can(self::DISABLE_PERMISSION)) { + return $shopProduct->disabled ? __('disabled') : __('enabled'); + } + $checked = $shopProduct->disabled == false ? 'checked' : ''; return ' diff --git a/app/Http/Controllers/Admin/TicketCategoryController.php b/app/Http/Controllers/Admin/TicketCategoryController.php index afd60fa93..5a5d3f6f6 100644 --- a/app/Http/Controllers/Admin/TicketCategoryController.php +++ b/app/Http/Controllers/Admin/TicketCategoryController.php @@ -6,6 +6,7 @@ use App\Models\Ticket; use App\Models\TicketCategory; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class TicketCategoryController extends Controller { @@ -35,11 +36,11 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'name' => 'required|string|max:191', ]); - TicketCategory::create($request->all()); + TicketCategory::create($validated); return redirect(route("admin.ticket.category.index"))->with("success",__("Category created")); @@ -80,19 +81,18 @@ public function destroy($id) $this->checkPermission(self::WRITE_PERMISSION); $category = TicketCategory::where("id",$id)->firstOrFail(); + $defaultCategory = TicketCategory::query()->where('name', 'Other')->first(); - if($category->id == 5 ){ //cannot delete "other" category + if (! $defaultCategory || $category->is($defaultCategory)) { return back()->with("error","You cannot delete that category"); } - $tickets = Ticket::where("ticketcategory_id",$category->id)->get(); - - foreach($tickets as $ticket){ - $ticket->ticketcategory_id = "5"; - $ticket->save(); - } + DB::transaction(function () use ($category, $defaultCategory) { + Ticket::where("ticketcategory_id", $category->id) + ->update(['ticketcategory_id' => $defaultCategory->id]); - $category->delete(); + $category->delete(); + }); return redirect() ->route('admin.ticket.category.index') diff --git a/app/Http/Controllers/Admin/TicketsController.php b/app/Http/Controllers/Admin/TicketsController.php index 083367c6f..8e3c42b9e 100644 --- a/app/Http/Controllers/Admin/TicketsController.php +++ b/app/Http/Controllers/Admin/TicketsController.php @@ -13,8 +13,10 @@ use App\Settings\LocaleSettings; use App\Settings\PterodactylSettings; use Exception; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; class TicketsController extends Controller { @@ -38,9 +40,8 @@ public function show($ticket_id, PterodactylSettings $ptero_settings) { $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION]); try { - $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); - } catch (Exception $e) - { + $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('Ticket not found on the server. It potentially got deleted earlier')); } $ticketcomments = $ticket->ticketcomments; @@ -55,9 +56,8 @@ public function changeStatus($ticket_id) { $this->checkPermission(self::WRITE_PERMISSION); try { - $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); - } catch(Exception $e) - { + $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('Ticket not found on the server. It potentially got deleted earlier')); } @@ -77,14 +77,15 @@ public function delete($ticket_id) { $this->checkPermission(self::WRITE_PERMISSION); try { - $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); - } catch (Exception $e) - { + $ticket = Ticket::where('ticket_id', $ticket_id)->firstOrFail(); + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('Ticket not found on the server. It potentially got deleted earlier')); } - TicketComment::where('ticket_id', $ticket->id)->delete(); - $ticket->delete(); + DB::transaction(function () use ($ticket) { + TicketComment::where('ticket_id', $ticket->id)->delete(); + $ticket->delete(); + }); return redirect()->back()->with('success', __('A ticket has been deleted, ID: #').$ticket_id); } @@ -97,7 +98,7 @@ public function reply(Request $request) $this->validate($request, ['ticketcomment' => 'required']); try { $ticket = Ticket::where('id', $request->input('ticket_id'))->firstOrFail(); - } catch (Exception $e){ + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('Ticket not found on the server. It potentially got deleted earlier')); } $ticket->status = 'Answered'; @@ -108,9 +109,8 @@ public function reply(Request $request) 'ticketcomment' => $request->input('ticketcomment'), ]); try { - $user = User::where('id', $ticket->user_id)->firstOrFail(); - } catch(Exception $e) - { + $user = User::where('id', $ticket->user_id)->firstOrFail(); + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('User not found on the server. Check on the admin database or try again later.')); } $newmessage = $request->input('ticketcomment'); @@ -134,7 +134,7 @@ public function dataTable() return ''.'#'.$tickets->ticket_id.' - '.htmlspecialchars($tickets->title).''; }) ->editColumn('user_id', function (Ticket $tickets) { - return ''.$tickets->user->name.''; + return ''.e($tickets->user->name).''; }) ->addColumn('actions', function (Ticket $tickets) { $statusButtonColor = ($tickets->status == "Closed") ? 'btn-success' : 'btn-warning'; @@ -199,20 +199,24 @@ public function blacklistAdd(Request $request) { $this->checkPermission(self::BLACKLIST_WRITE_PERMISSION); + $validated = $request->validate([ + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'reason' => ['nullable', 'string', 'max:2000'], + ]); + try { - $user = User::where('id', $request->user_id)->firstOrFail(); + $user = User::where('id', $validated['user_id'])->firstOrFail(); - if (auth()->user()->roles()->first()->power < $user->roles()->first()->power) { + if ((int) auth()->user()->roles()->max('power') < (int) $user->roles()->max('power')) { return redirect()->back()->with('warning', __('You cannot blacklist a user with higher power than you.')); } $check = TicketBlacklist::where('user_id', $user->id)->first(); - } - catch (Exception $e){ + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('User not found on the server. Check the admin database or try again later.')); } if ($check) { - $check->reason = $request->reason; + $check->reason = $validated['reason'] ?? null; $check->status = 'True'; $check->save(); @@ -221,7 +225,7 @@ public function blacklistAdd(Request $request) TicketBlacklist::create([ 'user_id' => $user->id, 'status' => 'True', - 'reason' => $request->reason, + 'reason' => $validated['reason'] ?? null, ]); return redirect()->back()->with('success', __('Successfully add User to blacklist, User name: '.$user->name)); @@ -231,7 +235,7 @@ public function blacklistDelete($id) { $this->checkPermission(self::BLACKLIST_WRITE_PERMISSION); - $blacklist = TicketBlacklist::where('id', $id)->first(); + $blacklist = TicketBlacklist::where('id', $id)->firstOrFail(); $blacklist->delete(); return redirect()->back()->with('success', __('Successfully remove User from blacklist, User name: '.$blacklist->user->name)); @@ -242,9 +246,8 @@ public function blacklistChange($id) $this->checkPermission(self::BLACKLIST_WRITE_PERMISSION); try { - $blacklist = TicketBlacklist::where('id', $id)->first(); - } - catch (Exception $e){ + $blacklist = TicketBlacklist::where('id', $id)->firstOrFail(); + } catch (ModelNotFoundException $e) { return redirect()->back()->with('warning', __('User not found on the server. Check the admin database or try again later.')); } if ($blacklist->status == 'True') { @@ -266,7 +269,7 @@ public function dataTableBlacklist() return datatables($query) ->editColumn('user', function (TicketBlacklist $blacklist) { - return ''.$blacklist->user->name.''; + return ''.e($blacklist->user->name).''; }) ->editColumn('status', function (TicketBlacklist $blacklist) { switch ($blacklist->status) { diff --git a/app/Http/Controllers/Admin/UsefulLinkController.php b/app/Http/Controllers/Admin/UsefulLinkController.php index 6846156d1..81c6af162 100644 --- a/app/Http/Controllers/Admin/UsefulLinkController.php +++ b/app/Http/Controllers/Admin/UsefulLinkController.php @@ -6,12 +6,14 @@ use App\Http\Controllers\Controller; use App\Models\UsefulLink; use App\Settings\LocaleSettings; +use App\Support\HtmlSanitizer; use Illuminate\Contracts\Foundation\Application; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Validation\Rules\Enum; class UsefulLinkController extends Controller { @@ -52,20 +54,22 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'icon' => 'required|string', 'title' => 'required|string|max:60', 'link' => 'required|url|string|max:191', 'description' => 'required|string|max:2000', + 'position' => 'required|array|min:1', + 'position.*' => ['required', new Enum(UsefulLinkLocation::class)], ]); UsefulLink::create([ - 'icon' => $request->icon, - 'title' => $request->title, - 'link' => $request->link, - 'description' => $request->description, - 'position' => implode(",",$request->position), + 'icon' => HtmlSanitizer::sanitizeIconClass($validated['icon']), + 'title' => $validated['title'], + 'link' => $validated['link'], + 'description' => HtmlSanitizer::sanitizeRichText($validated['description']), + 'position' => implode(",", $validated['position']), ]); return redirect()->route('admin.usefullinks.index')->with('success', __('link has been created!')); @@ -79,7 +83,7 @@ public function store(Request $request) */ public function show(UsefulLink $usefullink) { - // + abort(404); } /** @@ -110,19 +114,21 @@ public function update(Request $request, UsefulLink $usefullink) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'icon' => 'required|string', 'title' => 'required|string|max:60', 'link' => 'required|url|string|max:191', 'description' => 'required|string|max:2000', + 'position' => 'required|array|min:1', + 'position.*' => ['required', new Enum(UsefulLinkLocation::class)], ]); $usefullink->update([ - 'icon' => $request->icon, - 'title' => $request->title, - 'link' => $request->link, - 'description' => $request->description, - 'position' => implode(",",$request->position), + 'icon' => HtmlSanitizer::sanitizeIconClass($validated['icon']), + 'title' => $validated['title'], + 'link' => $validated['link'], + 'description' => HtmlSanitizer::sanitizeRichText($validated['description']), + 'position' => implode(",", $validated['position']), ]); return redirect()->route('admin.usefullinks.index')->with('success', __('link has been updated!')); @@ -139,7 +145,7 @@ public function destroy(UsefulLink $usefullink) $this->checkPermission(self::WRITE_PERMISSION); $usefullink->delete(); - return redirect()->back()->with('success', __('product has been removed!')); + return redirect()->back()->with('success', __('link has been removed!')); } public function dataTable() @@ -164,7 +170,7 @@ public function dataTable() return $link->created_at ? $link->created_at->diffForHumans() : ''; }) ->editColumn('icon', function (UsefulLink $link) { - return ""; + return ''; }) ->rawColumns(['actions', 'icon']) ->make(); diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 11b6d6491..f1df8d18e 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin; +use App\Constants\Roles; use App\Events\UserUpdateCreditsEvent; use App\Http\Controllers\Controller; use App\Models\User; @@ -24,6 +25,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Notification; use Illuminate\Support\HtmlString; +use Illuminate\Support\Collection; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Spatie\QueryBuilder\QueryBuilder; @@ -49,11 +51,10 @@ class UserController extends Controller const LOGIN_PERMISSION = "admin.users.login_as"; - private $pterodactyl; + private ?PterodactylClient $pterodactyl = null; - public function __construct(PterodactylSettings $ptero_settings) + public function __construct() { - $this->pterodactyl = new PterodactylClient($ptero_settings); } /** @@ -179,11 +180,14 @@ public function edit(User $user, GeneralSettings $general_settings) $permissions = array_filter($allConstants, fn($key) => str_starts_with($key, 'admin.users.write')); $this->checkAnyPermission($permissions); - $roles = Role::all(); + $canChangeRole = $this->can(self::CHANGE_ROLE_PERMISSION) && $this->canManageUserRoles($user); + $roles = $canChangeRole ? $this->assignableRolesFor(Auth::user()) : collect(); + return view('admin.users.edit')->with([ 'user' => $user, 'credits_display_name' => $general_settings->credits_display_name, - 'roles' => $roles + 'roles' => $roles, + 'can_change_role' => $canChangeRole, ]); } @@ -200,14 +204,14 @@ public function update(Request $request, User $user) { $this->checkAnyPermission([ self::WRITE_PERMISSION, - self::CHANGE_EMAIL_PERMISSION, - self::CHANGE_CREDITS_PERMISSION, self::CHANGE_USERNAME_PERMISSION, - self::CHANGE_PASSWORD_PERMISSION, - self::CHANGE_ROLE_PERMISSION, - self::CHANGE_REFERRAL_PERMISSION, + self::CHANGE_CREDITS_PERMISSION, self::CHANGE_PTERO_PERMISSION, + self::CHANGE_REFERRAL_PERMISSION, + self::CHANGE_EMAIL_PERMISSION, self::CHANGE_SERVERLIMIT_PERMISSION, + self::CHANGE_PASSWORD_PERMISSION, + self::CHANGE_ROLE_PERMISSION, ]); $data = $request->validate([ @@ -219,13 +223,40 @@ public function update(Request $request, User $user) 'referral_code' => "required|string|min:2|max:32|unique:users,referral_code,{$user->id}", ]); - //update roles - if ($request->roles && $this->can(self::CHANGE_ROLE_PERMISSION)) { - $collectedRoles = collect($request->roles)->map(fn($val)=>(int)$val); - $user->syncRoles($collectedRoles); + $rolesToSync = null; + if ($request->filled('role_id') || $request->filled('roles')) { + if (! $this->can(self::CHANGE_ROLE_PERMISSION) || ! $this->canManageUserRoles($user)) { + abort(403, __('You are not allowed to change this user\'s role.')); + } + + $rolesToSync = $this->requestedRoleIds($request); + + if ($rolesToSync->isEmpty()) { + throw ValidationException::withMessages([ + 'role_id' => [__('Please select a valid role.')], + ]); + } + + $requestedRoles = Role::query() + ->with('permissions') + ->whereIn('id', $rolesToSync) + ->get(); + + if ($requestedRoles->count() !== $rolesToSync->count()) { + throw ValidationException::withMessages([ + 'role_id' => [__('Please select a valid role.')], + ]); + } + + foreach ($requestedRoles as $role) { + if (! $this->canAssignRole(Auth::user(), $role)) { + abort(403, __('You are not allowed to assign the selected role.')); + } + } } - if (isset($this->pterodactyl->getUser($request->input('pterodactyl_id'))['errors'])) { + $pterodactylId = (int) $request->input('pterodactyl_id'); + if (isset($this->pterodactyl()->getUser($pterodactylId)['errors'])) { throw ValidationException::withMessages([ 'pterodactyl_id' => [__("User does not exists on pterodactyl's panel")], ]); @@ -257,6 +288,10 @@ public function update(Request $request, User $user) $dataArray['server_limit'] = $request->input('server_limit'); } + $shouldSyncPterodactyl = $request->filled('new_password') + || array_key_exists('name', $dataArray) + || array_key_exists('email', $dataArray) + || array_key_exists('pterodactyl_id', $dataArray); // Update password separately with validation, if permission is granted if (!is_null($request->input('new_password')) && $this->canAny([self::CHANGE_PASSWORD_PERMISSION, self::WRITE_PERMISSION])) { @@ -268,26 +303,36 @@ public function update(Request $request, User $user) $dataArray['password'] = Hash::make($request->input('new_password')); } - // Only update with the collected data - if (!empty($dataArray)) { - $user->update($dataArray); + try { + DB::transaction(function () use ($user, $rolesToSync, $dataArray, $shouldSyncPterodactyl, $request) { + if ($rolesToSync instanceof Collection) { + $user->syncRoles($rolesToSync->all()); + } - try { - $pteroData = array_filter([ - "email" => $user->email, - "username" => $user->name, - "first_name" => $user->name, - "last_name" => $user->name, - "language" => "en", - "password" => $request->filled('new_password') ? $request->input('new_password') : null - ]); + if (!empty($dataArray)) { + $user->update($dataArray); + } - $this->pterodactyl->updateUser($user->pterodactyl_id, $pteroData); - } catch (Exception $e) { - Log::error($e->getMessage()); + if ($shouldSyncPterodactyl) { + $pteroData = array_filter([ + "email" => $user->email, + "username" => $user->name, + "first_name" => $user->name, + "last_name" => $user->name, + "language" => "en", + "password" => $request->filled('new_password') ? $request->input('new_password') : null + ]); + + $this->pterodactyl()->updateUser($user->pterodactyl_id, $pteroData); + } + }); + } catch (Exception $e) { + Log::error('Failed to update user via admin panel.', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); - return redirect()->back()->with('error', __('User updated, but failed to update on pterodactyl: ' . $e->getMessage())); - } + return redirect()->back()->with('error', __('Failed to update the user on Pterodactyl. The changes were not saved.')); } event(new UserUpdateCreditsEvent($user)); @@ -295,6 +340,93 @@ public function update(Request $request, User $user) return redirect()->route('admin.users.index')->with('success', 'User updated!'); } + private function pterodactyl(): PterodactylClient + { + if ($this->pterodactyl === null) { + $this->pterodactyl = new PterodactylClient(app(PterodactylSettings::class)); + } + + return $this->pterodactyl; + } + + private function requestedRoleIds(Request $request): Collection + { + $requestedRoles = $request->input('role_id', $request->input('roles')); + + if ($requestedRoles === null || $requestedRoles === '') { + return collect(); + } + + return collect(is_array($requestedRoles) ? $requestedRoles : [$requestedRoles]) + ->filter(fn ($roleId) => $roleId !== null && $roleId !== '') + ->map(fn ($roleId) => (int) $roleId) + ->values(); + } + + private function assignableRolesFor(User $user): Collection + { + return Role::query() + ->with('permissions') + ->get() + ->filter(fn (Role $role) => $this->canAssignRole($user, $role)) + ->sortByDesc('power') + ->values(); + } + + private function canManageUserRoles(User $user): bool + { + return $user->roles() + ->with('permissions') + ->get() + ->every(fn (Role $role) => $this->canAssignRole(Auth::user(), $role)); + } + + private function canAssignRole(User $actor, Role $role): bool + { + if ($actor->can('*') || $actor->hasRole('Admin')) { + return true; + } + + if ($this->roleProvidesAdminAreaAccess($role)) { + return false; + } + + return (int) ($role->power ?? 0) <= $this->highestRolePower($actor); + } + + private function highestRolePower(User $user): int + { + return (int) ($user->roles()->max('power') ?? 0); + } + + private function roleProvidesAdminAreaAccess(Role $role): bool + { + if ($role->id === Roles::ADMIN_ROLE_ID || $role->name === 'Admin') { + return true; + } + + $permissions = $role->relationLoaded('permissions') + ? $role->permissions + : $role->permissions()->get(); + + return $permissions->pluck('name')->contains( + fn (string $permission) => $permission === '*' + || str_starts_with($permission, 'admin.') + || str_starts_with($permission, 'settings.') + ); + } + + private function userCanAccessAdminArea(User $user): bool + { + if ($user->can('*') || $user->hasRole('Admin')) { + return true; + } + + return $user->getAllPermissions() + ->pluck('name') + ->contains(fn (string $permission) => str_starts_with($permission, 'admin.') || str_starts_with($permission, 'settings.')); + } + /** * Remove the specified resource from storage. * @@ -305,7 +437,7 @@ public function destroy(User $user) { $this->checkPermission(self::DELETE_PERMISSION); - if ($user->hasRole(1) && User::role(1)->count() === 1) { + if ($user->hasRole('Admin') && User::role('Admin')->count() === 1) { return redirect()->back()->with('error', __('You can not delete the last admin!')); } @@ -322,10 +454,15 @@ public function destroy(User $user) */ public function verifyEmail(User $user) { - $this->checkPermission(self::WRITE_PERMISSION); + $this->checkAnyPermission([self::CHANGE_EMAIL_PERMISSION, self::WRITE_PERMISSION]); $user->verifyEmail(); + activity() + ->performedOn($user) + ->causedBy(Auth::user()) + ->log('verified email via admin panel'); + return redirect()->back()->with('success', __('Email has been verified!')); } @@ -338,6 +475,10 @@ public function loginAs(Request $request, User $user) { $this->checkPermission(self::LOGIN_PERMISSION); + if ($this->userCanAccessAdminArea($user)) { + abort(403, __('You cannot impersonate another administrative account.')); + } + $request->session()->put('previousUser', Auth::user()->id); Auth::login($user); @@ -350,10 +491,12 @@ public function loginAs(Request $request, User $user) */ public function logBackIn(Request $request) { - $this->checkPermission(self::LOGIN_PERMISSION); + $previousUserId = $request->session()->pull('previousUser'); + if (empty($previousUserId)) { + abort(403, __('No impersonation session could be restored.')); + } - Auth::loginUsingId($request->session()->get('previousUser'), true); - $request->session()->remove('previousUser'); + Auth::loginUsingId($previousUserId, true); return redirect()->route('admin.users.index'); } @@ -386,18 +529,24 @@ public function notify(Request $request) { $this->checkPermission(self::NOTIFY_PERMISSION); -//TODO: reimplement the required validation on all,users and roles . didnt work -- required_without:users,roles $data = $request->validate([ 'via' => 'required|min:1|array', 'via.*' => 'required|string|in:mail,database', 'all' => 'boolean', 'users' => 'min:1|array', + 'users.*' => 'integer|exists:users,id', 'roles' => 'min:1|array', 'roles.*' => 'required_without:all,users|exists:roles,id', 'title' => 'required|string|min:1', 'content' => 'required|string|min:1', ]); + if (! ($data['all'] ?? false) && empty($data['users']) && empty($data['roles'])) { + throw ValidationException::withMessages([ + 'users' => __('Please choose at least one recipient group or enable "all users".'), + ]); + } + $mail = null; $database = null; if (in_array('database', $data['via'])) { @@ -409,7 +558,7 @@ public function notify(Request $request) if (in_array('mail', $data['via'])) { $mail = (new MailMessage) ->subject($data['title']) - ->markdown('mail.custom', ['content' => $data['content']]); + ->line(strip_tags($data['content'])); } $all = $data['all'] ?? false; $roles = $data['roles'] ?? false; @@ -474,18 +623,28 @@ public function toggleSuspended(User $user) */ public function dataTable(Request $request) { - $this->checkPermission(self::READ_PERMISSION); + $this->checkAnyPermission([self::READ_PERMISSION, self::WRITE_PERMISSION]); - $query = User::with('discordUser') + $query = User::query() + ->with(['discordUser', 'roles']) ->withCount('servers') - ->leftJoin('model_has_roles', 'users.id', '=', 'model_has_roles.model_id') - ->leftJoin('roles', 'model_has_roles.role_id', '=', 'roles.id') - ->selectRaw('users.*, roles.name as role_name, (SELECT COUNT(*) FROM user_referrals WHERE user_referrals.referral_id = users.id) as referrals_count') - ->where('model_has_roles.model_type', User::class); + ->select('users.*') + ->addSelect([ + 'role_name' => Role::query() + ->select('roles.name') + ->join('model_has_roles', 'roles.id', '=', 'model_has_roles.role_id') + ->whereColumn('model_has_roles.model_id', 'users.id') + ->where('model_has_roles.model_type', User::class) + ->orderByDesc('roles.power') + ->limit(1), + 'referrals_count' => DB::table('user_referrals') + ->selectRaw('COUNT(*)') + ->whereColumn('user_referrals.referral_id', 'users.id'), + ]); return datatables($query) ->addColumn('avatar', function (User $user) { - return ''; + return ''; }) ->addColumn('credits', function (User $user, CurrencyHelper $currencyHelper) { return ' ' . $currencyHelper->formatForDisplay($user->credits); @@ -500,28 +659,53 @@ public function dataTable(Request $request) $suspendColor = $user->isSuspended() ? 'btn-success' : 'btn-warning'; $suspendIcon = $user->isSuspended() ? 'fa-play-circle' : 'fa-pause-circle'; $suspendText = $user->isSuspended() ? __('Unsuspend') : __('Suspend'); + $actions = []; + + if (auth()->user()?->can(self::LOGIN_PERMISSION) && ! $this->userCanAccessAdminArea($user)) { + $actions[] = ' +
+ ' . csrf_field() . ' + +
'; + } + + if (auth()->user()?->can(self::WRITE_PERMISSION)) { + $actions[] = ' +
+ ' . csrf_field() . ' + +
+ '; + } + + if (auth()->user()?->can(self::READ_PERMISSION)) { + $actions[] = ''; + } - return ' - - - - -
- ' . csrf_field() . ' - -
-
- ' . csrf_field() . ' - ' . method_field('DELETE') . ' - -
- '; + if (auth()->user()?->can(self::SUSPEND_PERMISSION)) { + $actions[] = ' +
+ ' . csrf_field() . ' + +
'; + } + + if (auth()->user()?->can(self::DELETE_PERMISSION)) { + $actions[] = ' +
+ ' . csrf_field() . ' + ' . method_field('DELETE') . ' + +
'; + } + + return implode('', $actions); }) ->editColumn('role', function (User $user) { $html = ''; foreach ($user->roles as $role) { - $html .= "$role->name"; + $html .= "" . e($role->name) . ""; } return $html; @@ -530,7 +714,10 @@ public function dataTable(Request $request) return $user->last_seen ? $user->last_seen->diffForHumans() : __('Never'); }) ->editColumn('name', function (User $user, PterodactylSettings $ptero_settings) { - return '' . e($user->name) . ''; + $url = e(rtrim($ptero_settings->panel_url, '/')); + $pteroId = (int) $user->pterodactyl_id; + + return '' . e($user->name) . ''; }) ->orderColumn('role', 'role_name $1') ->rawColumns(['avatar', 'name', 'credits', 'role', 'usage', 'actions']) diff --git a/app/Http/Controllers/Admin/VoucherController.php b/app/Http/Controllers/Admin/VoucherController.php index 70c121260..952e056ed 100644 --- a/app/Http/Controllers/Admin/VoucherController.php +++ b/app/Http/Controllers/Admin/VoucherController.php @@ -16,6 +16,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class VoucherController extends Controller @@ -60,7 +61,7 @@ public function store(Request $request) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'memo' => 'nullable|string|max:191', 'code' => 'required|string|alpha_dash|max:36|min:4|unique:vouchers', 'uses' => 'required|numeric|max:2147483647|min:1', @@ -68,7 +69,7 @@ public function store(Request $request) 'expires_at' => 'nullable|multiple_date_format:d-m-Y H:i:s,d-m-Y|after:now|before:10 years', ]); - Voucher::create($request->except('_token')); + Voucher::create($validated); return redirect()->route('admin.vouchers.index')->with('success', __('voucher has been created!')); } @@ -81,7 +82,7 @@ public function store(Request $request) */ public function show(Voucher $voucher) { - // + abort(404); } /** @@ -110,7 +111,7 @@ public function update(Request $request, Voucher $voucher) { $this->checkPermission(self::WRITE_PERMISSION); - $request->validate([ + $validated = $request->validate([ 'memo' => 'nullable|string|max:191', 'code' => "required|string|alpha_dash|max:36|min:4|unique:vouchers,code,{$voucher->id}", 'uses' => 'required|numeric|max:2147483647|min:1', @@ -118,7 +119,7 @@ public function update(Request $request, Voucher $voucher) 'expires_at' => 'nullable|multiple_date_format:d-m-Y H:i:s,d-m-Y|after:now|before:10 years', ]); - $voucher->update($request->except('_token')); + $voucher->update($validated); return redirect()->route('admin.vouchers.index')->with('success', __('voucher has been updated!')); } @@ -161,41 +162,56 @@ public function redeem(Request $request, GeneralSettings $general_settings, Curr 'code' => 'required|exists:vouchers,code', ]); - //get voucher by code - $voucher = Voucher::where('code', '=', $request->input('code'))->firstOrFail(); - - //extra validations - if ($voucher->getStatus() == 'USES_LIMIT_REACHED') { - throw ValidationException::withMessages([ - 'code' => __('This voucher has reached the maximum amount of uses'), - ]); - } - - if ($voucher->getStatus() == 'EXPIRED') { - throw ValidationException::withMessages([ - 'code' => __('This voucher has expired'), - ]); - } - - if (! $request->user()->vouchers()->where('id', '=', $voucher->id)->get()->isEmpty()) { - throw ValidationException::withMessages([ - 'code' => __('You already redeemed this voucher code'), - ]); - } - - if ($request->user()->credits + $voucher->credits >= 99999999) { - throw ValidationException::withMessages([ - 'code' => "You can't redeem this voucher because you would exceed the limit of " . $general_settings->credits_display_name, - ]); - } - - //redeem voucher - $voucher->redeem($request->user()); + $redeemedCredits = DB::transaction(function () use ($request, $general_settings) { + $voucher = Voucher::query() + ->where('code', '=', $request->input('code')) + ->lockForUpdate() + ->firstOrFail(); + + $user = User::query() + ->whereKey($request->user()->id) + ->lockForUpdate() + ->firstOrFail(); + + $existingRedemption = DB::table('user_voucher') + ->where('user_id', $user->id) + ->where('voucher_id', $voucher->id) + ->lockForUpdate() + ->exists(); + + if ($existingRedemption) { + throw ValidationException::withMessages([ + 'code' => __('You already redeemed this voucher code'), + ]); + } + + if ($voucher->users()->count() >= $voucher->uses) { + throw ValidationException::withMessages([ + 'code' => __('This voucher has reached the maximum amount of uses'), + ]); + } + + if ($voucher->expires_at !== null && $voucher->expires_at->isPast()) { + throw ValidationException::withMessages([ + 'code' => __('This voucher has expired'), + ]); + } + + if ($user->credits + $voucher->credits >= 99999999) { + throw ValidationException::withMessages([ + 'code' => "You can't redeem this voucher because you would exceed the limit of " . $general_settings->credits_display_name, + ]); + } + + $voucher->redeem($user); + + return $voucher->credits; + }); event(new UserUpdateCreditsEvent($request->user())); return response()->json([ - 'success' => $currencyHelper->formatForDisplay($voucher->credits) . ' ' . $general_settings->credits_display_name . ' ' . __('have been added to your balance!'), + 'success' => $currencyHelper->formatForDisplay($redeemedCredits) . ' ' . $general_settings->credits_display_name . ' ' . __('have been added to your balance!'), ]); } @@ -207,7 +223,7 @@ public function usersDataTable(Voucher $voucher) return datatables($users) ->editColumn('name', function (User $user) { - return ''.$user->name.''; + return ''.e($user->name).''; }) ->addColumn('credits', function (User $user, CurrencyHelper $currencyHelper) { return ' '. $currencyHelper->formatForDisplay($user->credits); diff --git a/app/Http/Controllers/Api/Concerns/InteractsWithScopedApiTokens.php b/app/Http/Controllers/Api/Concerns/InteractsWithScopedApiTokens.php new file mode 100644 index 000000000..ac50f4e95 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/InteractsWithScopedApiTokens.php @@ -0,0 +1,83 @@ +attributes->get('apiToken'); + } + + protected function ownerScopedUserId(Request $request): ?int + { + return $this->currentApiToken($request)?->owner_user_id; + } + + protected function restrictUsersToTokenOwner(Request $request, $query) + { + $ownerUserId = $this->ownerScopedUserId($request); + + if ($ownerUserId !== null) { + $query->whereKey($ownerUserId); + } + + return $query; + } + + protected function restrictServersToTokenOwner(Request $request, $query) + { + $ownerUserId = $this->ownerScopedUserId($request); + + if ($ownerUserId !== null) { + $query->where('user_id', $ownerUserId); + } + + return $query; + } + + protected function ensureCanAccessUser(Request $request, User $user): void + { + $ownerUserId = $this->ownerScopedUserId($request); + + if ($ownerUserId !== null && $ownerUserId !== $user->id) { + abort(404); + } + } + + protected function ensureCanAccessServer(Request $request, Server $server): void + { + $ownerUserId = $this->ownerScopedUserId($request); + + if ($ownerUserId !== null && $ownerUserId !== $server->user_id) { + abort(404); + } + } + + protected function ensureTargetsOnlyTokenOwner(Request $request, array $userIds): void + { + $ownerUserId = $this->ownerScopedUserId($request); + + if ($ownerUserId === null) { + return; + } + + $normalizedIds = array_values(array_unique(array_map('intval', $userIds))); + + if ($normalizedIds !== [$ownerUserId]) { + abort(404); + } + } + + protected function ensureGlobalToken(Request $request): void + { + if ($this->ownerScopedUserId($request) !== null) { + abort(403, 'This API token cannot access global resources.'); + } + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php index b68776091..bbdf5f73f 100644 --- a/app/Http/Controllers/Api/NotificationController.php +++ b/app/Http/Controllers/Api/NotificationController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; use App\Http\Resources\NotificationResource; use App\Models\User; use App\Http\Controllers\Controller; @@ -12,13 +13,14 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Notifications\Messages\MailMessage; -use Illuminate\Support\HtmlString; use Illuminate\Validation\ValidationException; use Illuminate\Database\Eloquent\ModelNotFoundException; -use Exception; +use Throwable; class NotificationController extends Controller { + use InteractsWithScopedApiTokens; + public function __construct(protected NotificationService $notificationService) {} @@ -33,7 +35,10 @@ public function __construct(protected NotificationService $notificationService) */ public function index(Request $request, User $user) { - $notifications = $user->notifications()->paginate($request->query('per_page', 50)); + $this->ensureCanAccessUser($request, $user); + + $perPage = max(1, min((int) $request->query('per_page', 50), 100)); + $notifications = $user->notifications()->paginate($perPage); return NotificationResource::collection($notifications); } @@ -50,6 +55,9 @@ public function index(Request $request, User $user) */ public function view(Request $request, User $user, ModelNotification $notification) { + $this->ensureCanAccessUser($request, $user); + $this->ensureNotificationBelongsToUser($user, $notification); + return NotificationResource::make($notification); } @@ -65,6 +73,7 @@ public function sendToUsers(SendToUsersNotificationRequest $request) { try { $data = $request->validated(); + $this->ensureTargetsOnlyTokenOwner($request, $data['users']); $via = match($data['via']) { 'mail' => ['mail'], @@ -74,13 +83,13 @@ public function sendToUsers(SendToUsersNotificationRequest $request) $database = in_array('database', $via) ? [ 'title' => $data['title'], - 'content' => $data['content'], + 'content' => strip_tags($data['content']), ] : null; $mail = in_array('mail', $via) ? (new MailMessage) ->subject($data['title']) - ->line(new HtmlString($data['content'])) + ->line(strip_tags($data['content'])) : null; $users = $this->getTargetUsers($data); @@ -94,10 +103,14 @@ public function sendToUsers(SendToUsersNotificationRequest $request) 'channels' => $via ] ]); - } catch (Exception $e) { + } catch (ValidationException $e) { + throw $e; + } catch (Throwable $e) { + report($e); + return response()->json([ 'error' => 'Failed to send notification.', - 'message' => $e->getMessage() + 'message' => __('Failed to send notification.') ], 500); } } @@ -111,6 +124,8 @@ public function sendToUsers(SendToUsersNotificationRequest $request) public function sendToAll(SendToAllUsersNotificationRequest $request) { try { + $this->ensureGlobalToken($request); + $data = $request->validated(); $via = match($data['via']) { @@ -121,30 +136,36 @@ public function sendToAll(SendToAllUsersNotificationRequest $request) $database = in_array('database', $via) ? [ 'title' => $data['title'], - 'content' => $data['content'], + 'content' => strip_tags($data['content']), ] : null; $mail = in_array('mail', $via) ? (new MailMessage) ->subject($data['title']) - ->line(new HtmlString($data['content'])) + ->line(strip_tags($data['content'])) : null; - - $users = User::all(); - - $this->notificationService->sendToUsers($users, $via, $database, $mail); + + $userCount = User::query()->count(); + User::query() + ->chunkById(500, function ($users) use ($via, $database, $mail) { + $this->notificationService->sendToUsers($users, $via, $database, $mail); + }); return response()->json([ 'message' => 'Notification sent successfully.', 'meta' => [ - 'user_count' => $users->count(), + 'user_count' => $userCount, 'channels' => $via ] ]); - } catch (Exception $e) { + } catch (ValidationException $e) { + throw $e; + } catch (Throwable $e) { + report($e); + return response()->json([ 'error' => 'Failed to send notification.', - 'message' => $e->getMessage() + 'message' => __('Failed to send notification.') ], 500); } } @@ -160,6 +181,8 @@ public function sendToAll(SendToAllUsersNotificationRequest $request) */ public function destroyAll(Request $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $count = $user->notifications()->delete(); return response()->json([ @@ -182,6 +205,9 @@ public function destroyAll(Request $request, User $user) */ public function destroyOne(Request $request, User $user, ModelNotification $notification) { + $this->ensureCanAccessUser($request, $user); + $this->ensureNotificationBelongsToUser($user, $notification); + $notification->delete(); return response()->noContent(); @@ -207,4 +233,14 @@ private function getTargetUsers(array $data): \Illuminate\Database\Eloquent\Coll return $users; } + + private function ensureNotificationBelongsToUser(User $user, ModelNotification $notification): void + { + if ( + $notification->notifiable_type !== $user->getMorphClass() + || (string) $notification->notifiable_id !== (string) $user->getKey() + ) { + abort(404); + } + } } diff --git a/app/Http/Controllers/Api/ProductController.php b/app/Http/Controllers/Api/ProductController.php index 2ebf3f2cc..90694ab5e 100644 --- a/app/Http/Controllers/Api/ProductController.php +++ b/app/Http/Controllers/Api/ProductController.php @@ -2,9 +2,10 @@ namespace App\Http\Controllers\Api; -use App\Models\Product; use App\Http\Resources\ProductResource; use App\Http\Controllers\Controller; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; +use App\Models\Product; use App\Http\Requests\Api\Products\CreateProductRequest; use App\Http\Requests\Api\Products\UpdateProductRequest; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -13,6 +14,8 @@ class ProductController extends Controller { + use InteractsWithScopedApiTokens; + const ALLOWED_INCLUDES = ['servers.user', 'eggs.nest', 'nodes.location']; const ALLOWED_FILTERS = ['name', 'description', 'price']; @@ -24,10 +27,12 @@ class ProductController extends Controller */ public function index(Request $request) { + $this->ensureGlobalToken($request); + $products = QueryBuilder::for(Product::class) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters(self::ALLOWED_FILTERS) - ->paginate($request->input('per_page') ?? 50); + ->paginate($this->perPage($request)); return ProductResource::collection($products); } @@ -40,6 +45,8 @@ public function index(Request $request) */ public function store(CreateProductRequest $request) { + $this->ensureGlobalToken($request); + $data = $request->validated(); $product = Product::create($data); @@ -68,11 +75,13 @@ public function store(CreateProductRequest $request) * * @throws ModelNotFoundException */ - public function show(Request $request, string $productId) + public function show(Request $request, Product $product) { + $this->ensureGlobalToken($request); + $product = QueryBuilder::for(Product::class) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->where('id', $productId) + ->whereKey($product->id) ->firstOrFail(); return ProductResource::make($product); @@ -89,6 +98,8 @@ public function show(Request $request, string $productId) */ public function update(UpdateProductRequest $request, Product $product) { + $this->ensureGlobalToken($request); + $data = $request->validated(); $product->update($data); @@ -117,6 +128,8 @@ public function update(UpdateProductRequest $request, Product $product) */ public function destroy(Request $request, Product $product) { + $this->ensureGlobalToken($request); + if ($product->servers()->exists()) { return response()->json([ 'message' => 'Cannot delete product with associated servers.', @@ -130,4 +143,4 @@ public function destroy(Request $request, Product $product) return response()->noContent(); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/RoleController.php b/app/Http/Controllers/Api/RoleController.php index 91d7aec7a..313ac3b44 100644 --- a/app/Http/Controllers/Api/RoleController.php +++ b/app/Http/Controllers/Api/RoleController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Constants\Roles; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; use App\Http\Resources\RoleResource; use App\Models\User; use App\Models\Role; @@ -15,6 +16,8 @@ class RoleController extends Controller { + use InteractsWithScopedApiTokens; + const ALLOWED_INCLUDES = ['permissions', 'users']; const ALLOWED_FILTERS = ['name']; @@ -26,10 +29,12 @@ class RoleController extends Controller */ public function index(Request $request) { + $this->ensureGlobalToken($request); + $roles = QueryBuilder::for(Role::class) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters(self::ALLOWED_FILTERS) - ->paginate($request->input('per_page') ?? 50); + ->paginate($this->perPage($request)); return RoleResource::collection($roles); } @@ -42,6 +47,8 @@ public function index(Request $request) */ public function store(CreateRoleRequest $request) { + $this->ensureGlobalToken($request); + $data = $request->validated(); $role = Role::create($data); @@ -66,11 +73,13 @@ public function store(CreateRoleRequest $request) * * @throws ModelNotFoundException */ - public function show(Request $request, int $roleId) + public function show(Request $request, Role $role) { + $this->ensureGlobalToken($request); + $role = QueryBuilder::for(Role::class) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->where('id', $roleId) + ->whereKey($role->id) ->firstOrFail(); return RoleResource::make($role); @@ -87,6 +96,8 @@ public function show(Request $request, int $roleId) */ public function update(UpdateRoleRequest $request, Role $role) { + $this->ensureGlobalToken($request); + $data = $request->validated(); if (isset($data['permissions'])) { @@ -111,6 +122,8 @@ public function update(UpdateRoleRequest $request, Role $role) */ public function destroy(Request $request, Role $role) { + $this->ensureGlobalToken($request); + if (!$role->isDeletable()) { return response()->json(['error' => 'This role cannot be deleted.'], 403); } diff --git a/app/Http/Controllers/Api/ServerController.php b/app/Http/Controllers/Api/ServerController.php index cefd4d0cc..1f2d084b0 100644 --- a/app/Http/Controllers/Api/ServerController.php +++ b/app/Http/Controllers/Api/ServerController.php @@ -5,6 +5,7 @@ use App\Classes\PterodactylClient; use App\Events\ServerCreatedEvent; use App\Events\ServerDeletedEvent; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; use App\Models\Product; use App\Models\User; use App\Models\Server; @@ -22,22 +23,22 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Spatie\QueryBuilder\QueryBuilder; use Spatie\QueryBuilder\AllowedFilter; use Exception; class ServerController extends Controller { - protected PterodactylSettings $pterodactylSettings; - protected PterodactylClient $pterodactylClient; + use InteractsWithScopedApiTokens; + + protected ?PterodactylClient $pterodactylClient = null; public function __construct( protected ServerCreationService $serverCreationService, protected ServerUpgradeService $serverUpgradeService ) { - $this->pterodactylSettings = app(PterodactylSettings::class); - $this->pterodactylClient = app(PterodactylClient::class, [$this->pterodactylSettings]); } public const ALLOWED_INCLUDES = ['product', 'user']; @@ -51,13 +52,16 @@ public function __construct( */ public function index(Request $request) { - $servers = QueryBuilder::for(Server::class) + $servers = $this->restrictServersToTokenOwner( + $request, + QueryBuilder::for(Server::class) + ) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters([ AllowedFilter::exact('suspended')->nullable(), ...self::ALLOWED_FILTERS ]) - ->paginate($request->input('per_page') ?? 50); + ->paginate($this->perPage($request)); return ServerResource::collection($servers); } @@ -71,11 +75,16 @@ public function index(Request $request) * * @throws ModelNotFoundException */ - public function show(Request $request, string $serverId) + public function show(Request $request, Server $server) { - $server = QueryBuilder::for(Server::class) + $this->ensureCanAccessServer($request, $server); + + $server = $this->restrictServersToTokenOwner( + $request, + QueryBuilder::for(Server::class) + ) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->where('id', $serverId) + ->whereKey($server->id) ->firstOrFail(); return ServerResource::make($server); @@ -93,21 +102,27 @@ public function store(CreateServerRequest $request) { $data = $request->validated(); + if ($this->ownerScopedUserId($request) !== null && $this->ownerScopedUserId($request) !== (int) $data['user_id']) { + abort(403, 'This API token is restricted to its owner.'); + } + $user = User::findOrFail($data['user_id']); $product = Product::with('eggs')->findOrFail($data['product_id']); try { $server = $this->serverCreationService->handle($user, $product, $data); - $user->decrement("credits", $product->price); - event(new ServerCreatedEvent($user, $server)); return ServerResource::make($server->fresh()); } catch (Exception $e) { - return response()->json([ - 'message' => $e->getMessage() - ], 401); + logger()->warning('Failed to create server via API.', [ + 'user_id' => $user->id, + 'product_id' => $product->id, + 'error' => $e->getMessage(), + ]); + + return $this->serverErrorResponse($e, 'Failed to create the server.'); } } @@ -123,22 +138,52 @@ public function store(CreateServerRequest $request) */ public function update(UpdateServerRequest $request, Server $server) { + $this->ensureCanAccessServer($request, $server); + $data = $request->validated(); - $server->fill($data); + if ($this->ownerScopedUserId($request) !== null && isset($data['user_id']) && $this->ownerScopedUserId($request) !== (int) $data['user_id']) { + abort(403, 'This API token is restricted to its owner.'); + } try { - if ($server->isDirty(['name', 'description', 'user_id'])) { - $pteroData = array_merge($request->only(['name', 'description']), ['user' => $data['user_id']]); + $server = DB::transaction(function () use ($server, $data) { + $lockedServer = Server::query()->whereKey($server->id)->lockForUpdate()->firstOrFail(); + $lockedServer->fill($data); + + if ($lockedServer->isDirty(['name', 'description', 'user_id'])) { + $ownerChanged = $lockedServer->isDirty('user_id'); + $remoteUserId = $lockedServer->user_id; + + if ($ownerChanged) { + $owner = User::findOrFail($lockedServer->user_id); + + if (! $owner->pterodactyl_id) { + throw new Exception('Selected user is not synced with Pterodactyl.', 422); + } - $response = $this->pterodactylClient->updateServerDetails($server, $pteroData); + $remoteUserId = $owner->pterodactyl_id; + } - if (!$response->successful()) { - $response->throw(); + $pteroData = [ + 'name' => $lockedServer->name, + 'description' => $lockedServer->description, + 'user' => $remoteUserId, + ]; + + $lockedServer->save(); + + $response = $this->pterodactylClient()->updateServerDetails($lockedServer, $pteroData); + + if (! $response->successful()) { + $response->throw(); + } + } elseif ($lockedServer->isDirty()) { + $lockedServer->save(); } - } - $server->save(); + return $lockedServer; + }); return ServerResource::make($server->refresh()); } catch (Exception $e) { @@ -147,7 +192,7 @@ public function update(UpdateServerRequest $request, Server $server) 'server_id' => $server->id ]); - return response()->json(['message' => $e->getMessage()], 500); + return $this->serverErrorResponse($e, 'Failed to update the server.'); } } @@ -162,8 +207,14 @@ public function update(UpdateServerRequest $request, Server $server) */ public function updateBuild(UpdateServerBuildRequest $request, Server $server) { + $this->ensureCanAccessServer($request, $server); + $data = $request->validated(); + if ($this->ownerScopedUserId($request) !== null && $this->ownerScopedUserId($request) !== (int) $data['user_id']) { + abort(403, 'This API token is restricted to its owner.'); + } + $user = User::findOrFail($data['user_id']); $product = Product::findOrFail($data['product_id']); @@ -172,7 +223,12 @@ public function updateBuild(UpdateServerBuildRequest $request, Server $server) return ServerResource::make($server->fresh()); } catch (Exception $e) { - return response()->json(['message' => $e->getMessage()], $e->getCode() ?: 500); + logger()->warning('Failed to update server build via API.', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return $this->serverErrorResponse($e, 'Failed to update the server build.'); } } @@ -187,6 +243,8 @@ public function updateBuild(UpdateServerBuildRequest $request, Server $server) */ public function destroy(DeleteServerRequest $request, Server $server) { + $this->ensureCanAccessServer($request, $server); + $data = $request->validated(); try { @@ -202,7 +260,12 @@ public function destroy(DeleteServerRequest $request, Server $server) $server->delete(); } catch (Exception $e) { - return response()->json(['message' => $e->getMessage()], 500); + logger()->warning('Failed to delete server via API.', [ + 'server_id' => $server->id, + 'error' => $e->getMessage(), + ]); + + return $this->serverErrorResponse($e, 'Failed to delete the server.'); } return response()->noContent(); @@ -219,6 +282,8 @@ public function destroy(DeleteServerRequest $request, Server $server) */ public function suspend(SuspendServerRequest $request, Server $server) { + $this->ensureCanAccessServer($request, $server); + $data = $request->validated(); try { @@ -232,7 +297,12 @@ public function suspend(SuspendServerRequest $request, Server $server) $server->suspend(); } catch (Exception $exception) { - return response()->json(['message' => $exception->getMessage()], 500); + logger()->warning('Failed to suspend server via API.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + + return $this->serverErrorResponse($exception, 'Failed to suspend the server.'); } return ServerResource::make($server); @@ -249,6 +319,8 @@ public function suspend(SuspendServerRequest $request, Server $server) */ public function unSuspend(UnsuspendServerRequest $request, Server $server) { + $this->ensureCanAccessServer($request, $server); + $data = $request->validated(); try { @@ -262,9 +334,63 @@ public function unSuspend(UnsuspendServerRequest $request, Server $server) $server->unSuspend(); } catch (Exception $exception) { - return response()->json(['message' => $exception->getMessage()], 500); + logger()->warning('Failed to unsuspend server via API.', [ + 'server_id' => $server->id, + 'error' => $exception->getMessage(), + ]); + + return $this->serverErrorResponse($exception, 'Failed to unsuspend the server.'); } return ServerResource::make($server); } + + private function pterodactylClient(): PterodactylClient + { + if ($this->pterodactylClient === null) { + $this->pterodactylClient = app(PterodactylClient::class, [app(PterodactylSettings::class)]); + } + + return $this->pterodactylClient; + } + + private function serverErrorResponse(Exception $exception, string $defaultMessage): JsonResponse + { + $message = $this->publicServerErrorMessage($exception->getMessage(), $defaultMessage); + $status = $message === $defaultMessage && $this->validClientErrorStatus($exception->getCode()) + ? $exception->getCode() + : ($message === $defaultMessage ? 500 : 422); + + return response()->json(['message' => $message], $status); + } + + private function publicServerErrorMessage(string $message, string $defaultMessage): string + { + $safeMessages = [ + 'Server limit reached for this product.', + 'Server limit reached for this user and product combination.', + 'User must verify their email before creating a server.', + 'User must link their Discord account before creating a server.', + 'Server creation is currently disabled.', + 'No available nodes for this product in the selected location.', + 'No free allocation available on the selected node.', + 'Insufficient resources on the node to upgrade the server.', + 'Insufficient credits to upgrade the server.', + 'Selected egg is not available for this product.', + 'Selected product is not available on the current node.', + 'Selected product is not compatible with the current egg.', + 'Selected user is not synced with Pterodactyl.', + ]; + + if (in_array($message, $safeMessages, true) || str_starts_with($message, 'User do not have the required amount of ')) { + return $message; + } + + return $defaultMessage; + } + + private function validClientErrorStatus(mixed $status): bool + { + return is_int($status) && $status >= 400 && $status < 500; + } } diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 8760ad470..73a05098a 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -5,7 +5,10 @@ use App\Classes\PterodactylClient; use App\Events\UserUpdateCreditsEvent; use App\Helpers\CurrencyHelper; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; use App\Http\Resources\UserResource; +use App\Models\ApplicationApi; +use App\Models\Role; use App\Models\User; use App\Notifications\ReferralNotification; use App\Settings\PterodactylSettings; @@ -32,20 +35,20 @@ class UserController extends Controller { use Referral; + use InteractsWithScopedApiTokens; - private $pterodactyl; + private ?PterodactylClient $pterodactyl = null; private $currencyHelper; private $referralSettings; - public function __construct(PterodactylSettings $ptero_settings, ReferralSettings $referralSettings, CurrencyHelper $currencyHelper) + public function __construct(ReferralSettings $referralSettings, CurrencyHelper $currencyHelper) { - $this->pterodactyl = new PterodactylClient($ptero_settings); $this->referralSettings = $referralSettings; $this->currencyHelper = $currencyHelper; } - const ALLOWED_INCLUDES = ['servers.product', 'notifications', 'payments', 'vouchers.users', 'roles.permissions', 'discordUser']; - const ALLOWED_FILTERS = ['name', 'server_limit', 'email', 'pterodactyl_id', 'suspended']; + const ALLOWED_INCLUDES = ['servers.product', 'notifications', 'payments', 'vouchers', 'roles.permissions']; + const ALLOWED_FILTERS = ['name', 'server_limit', 'suspended']; /** * Show a list of users. @@ -55,10 +58,13 @@ public function __construct(PterodactylSettings $ptero_settings, ReferralSetting */ public function index(Request $request) { - $users = QueryBuilder::for(User::class) - ->allowedIncludes(self::ALLOWED_INCLUDES) - ->allowedFilters(self::ALLOWED_FILTERS) - ->paginate($request->input('per_page') ?? 50); + $users = $this->restrictUsersToTokenOwner( + $request, + QueryBuilder::for(User::class) + ) + ->allowedIncludes($this->allowedIncludes($request)) + ->allowedFilters($this->allowedFilters($request)) + ->paginate($this->perPage($request)); return UserResource::collection($users); } @@ -66,7 +72,7 @@ public function index(Request $request) /** * Show the specified user. * - * @queryParam include string Comma-separated list of related resources to include. Example: servers.product,notifications,payments,vouchers.users,roles.permissions,discordUser + * @queryParam include string Comma-separated list of related resources to include. Example: servers.product,notifications,payments,vouchers,roles.permissions,discordUser * * @param Request $request * @param int $userId @@ -74,11 +80,16 @@ public function index(Request $request) * * @throws ModelNotFoundException */ - public function show(Request $request, int $userId) + public function show(Request $request, User $user) { - $user = QueryBuilder::for(User::class) - ->allowedIncludes(self::ALLOWED_INCLUDES) - ->where('id', $userId) + $this->ensureCanAccessUser($request, $user); + + $user = $this->restrictUsersToTokenOwner( + $request, + QueryBuilder::for(User::class) + ) + ->allowedIncludes($this->allowedIncludes($request)) + ->whereKey($user->id) ->firstOrFail(); return UserResource::make($user); @@ -96,48 +107,96 @@ public function show(Request $request, int $userId) */ public function update(UpdateUserRequest $request, User $user) { - $data = $request->validated(); + $this->ensureCanAccessUser($request, $user); - try { - $payload = array_filter([ - 'username' => $data['name'], - 'first_name' => $data['name'], - 'last_name' => $data['name'], - 'email' => $data['email'], - 'password' => isset($data['password']) ? $data['password'] : null, - ]); + $data = $request->validated(); - $response = $this->pterodactyl->application->patch('/application/users/' . $user->pterodactyl_id, $payload); + if (isset($data['role_id'])) { + // Prevent role changes via API for security - roles should only be changed through admin UI + // where proper authorization and audit logging is enforced + abort(403, 'Role changes are not permitted via API. Use the admin interface instead.'); + } - if ($response->failed()) { - throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $response->toException()->getMessage(), - 'pterodactyl_error_status' => $response->toException()->getCode(), - ]); + try { + $updateData = []; + + if (array_key_exists('name', $data)) { + $pteroPayload = [ + 'username' => $data['name'], + 'first_name' => $data['name'], + 'last_name' => $data['name'], + 'email' => $data['email'] ?? $user->email, + ]; + + if (array_key_exists('password', $data)) { + $pteroPayload['password'] = $data['password']; + } + + $response = $this->pterodactyl()->application->patch('/application/users/' . $user->pterodactyl_id, $pteroPayload); + + if ($response->failed()) { + logger()->warning('Failed to update user in Pterodactyl.', [ + 'user_id' => $user->id, + 'status' => $response->status(), + ]); + + throw $this->pterodactylValidationException(__('Failed to sync the user with Pterodactyl.')); + } + } elseif (array_key_exists('email', $data) || array_key_exists('password', $data)) { + $pteroPayload = [ + 'username' => $user->name, + 'first_name' => $user->name, + 'last_name' => $user->name, + 'email' => $data['email'] ?? $user->email, + ]; + + if (array_key_exists('password', $data)) { + $pteroPayload['password'] = $data['password']; + } + + $response = $this->pterodactyl()->application->patch('/application/users/' . $user->pterodactyl_id, $pteroPayload); + + if ($response->failed()) { + logger()->warning('Failed to update user in Pterodactyl.', [ + 'user_id' => $user->id, + 'status' => $response->status(), + ]); + + throw $this->pterodactylValidationException(__('Failed to sync the user with Pterodactyl.')); + } } - if (isset($data['role_id'])) { - $user->syncRoles([$data['role_id']]); - unset($data['role_id']); + foreach (['name', 'email', 'credits', 'server_limit'] as $field) { + if (array_key_exists($field, $data)) { + $updateData[$field] = $data[$field]; + } } - $dataPayload = array_filter([ - ...$data, - 'password' => isset($data['password']) ? Hash::make($data['password']) : null, - ]); + if (array_key_exists('password', $data)) { + $updateData['password'] = Hash::make($data['password']); + } - $user->update($dataPayload); + if (! empty($updateData)) { + $user->update($updateData); + } - event(new UserUpdateCreditsEvent($user)); + if (array_key_exists('credits', $data) || array_key_exists('server_limit', $data)) { + event(new UserUpdateCreditsEvent($user)); + } return UserResource::make($user); } catch (Exception $e) { - report($e); + if ($e instanceof ValidationException) { + throw $e; + } - throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $e->getMessage(), - 'pterodactyl_error_status' => $e->getCode(), + report($e); + logger()->warning('Failed to update user via API.', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), ]); + + throw $this->pterodactylValidationException(__('Failed to sync the user with Pterodactyl.')); } } @@ -153,6 +212,8 @@ public function update(UpdateUserRequest $request, User $user) */ public function increment(IncrementRequest $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $data = $request->validated(); if (isset($data['credits'])) { @@ -180,6 +241,8 @@ public function increment(IncrementRequest $request, User $user) */ public function decrement(DecrementRequest $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $data = $request->validated(); if (isset($data['credits'])) { @@ -204,6 +267,8 @@ public function decrement(DecrementRequest $request, User $user) */ public function suspend(SuspendUserRequest $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $data = $request->validated(); if ($user->isSuspended()) { @@ -236,6 +301,8 @@ public function suspend(SuspendUserRequest $request, User $user) */ public function unsuspend(UnsuspendUserRequest $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $data = $request->validated(); if (!$user->isSuspended()) { @@ -267,59 +334,91 @@ public function unsuspend(UnsuspendUserRequest $request, User $user) */ public function store(CreateUserRequest $request, UserSettings $userSettings) { - $data = $request->validated(); + $this->ensureGlobalToken($request); - DB::beginTransaction(); + $data = $request->validated(); + $this->ensureApiRoleIsAssignable((int) $data['role_id']); + $remotePterodactylId = null; try { - $role_id = $data['role_id']; - unset($data['role_id']); - - $user = User::create([ - ...$data, - 'credits' => isset($data['credits']) ? $this->currencyHelper->prepareForDatabase($data['credits']) : $userSettings->initial_credits, - 'server_limit' => $data['server_limit'] ?? $userSettings->initial_server_limit, - 'referral_code' => $this->createReferralCode(), - ]); + $user = DB::transaction(function () use ($data, $userSettings, &$remotePterodactylId) { + $roleId = $data['role_id']; + $createData = $data; + unset($createData['role_id']); + + $user = User::create([ + ...$createData, + 'credits' => isset($createData['credits']) ? $this->currencyHelper->prepareForDatabase($createData['credits']) : $userSettings->initial_credits, + 'server_limit' => $createData['server_limit'] ?? $userSettings->initial_server_limit, + 'referral_code' => $this->createReferralCode(), + ]); - $user->syncRoles([$role_id]); + $user->syncRoles([$roleId]); + + $response = $this->pterodactyl()->application->post('/application/users', [ + 'external_id' => (string) $user->id, + 'username' => $createData['name'], + 'email' => $createData['email'], + 'first_name' => $createData['name'], + 'last_name' => $createData['name'], + 'password' => $createData['password'], + 'root_admin' => false, + 'language' => 'en', + ]); - $this->incrementReferralUserCredits($user, $data); + if ($response->failed()) { + logger()->warning('Failed to create user in Pterodactyl.', [ + 'email_sha256' => $this->hashEmail($createData['email']), + 'status' => $response->status(), + ]); - $response = $this->pterodactyl->application->post('/application/users', [ - 'external_id' => "0", - 'username' => $data['name'], - 'email' => $data['email'], - 'first_name' => $data['name'], - 'last_name' => $data['name'], - 'password' => $data['password'], - 'root_admin' => false, - 'language' => 'en', - ]); + throw $this->pterodactylValidationException(__('Failed to create the user on Pterodactyl.')); + } + + $remotePterodactylId = data_get($response->json(), 'attributes.id'); - if ($response->failed()) { - throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $response->toException()->getMessage(), - 'pterodactyl_error_status' => $response->toException()->getCode(), + if (! is_int($remotePterodactylId) && ! ctype_digit((string) $remotePterodactylId)) { + throw $this->pterodactylValidationException(__('Failed to create the user on Pterodactyl.')); + } + + $user->update([ + 'pterodactyl_id' => (int) $remotePterodactylId, ]); - } - $user->update([ - 'pterodactyl_id' => $response->json()['attributes']['id'], - ]); + $this->incrementReferralUserCredits($user, $createData); - $user->sendEmailVerificationNotification(); + DB::afterCommit(static function () use ($user): void { + $user->sendEmailVerificationNotification(); + }); - DB::commit(); + activity()->performedOn($user)->log(sprintf('The user %s (ID: %d) was created via API', $user->name, $user->id)); - return UserResource::make($user); + return $user; + }); + + return UserResource::make($user->fresh()); } catch (Exception $e) { - DB::rollBack(); + if ($remotePterodactylId) { + try { + $this->pterodactyl()->application->delete('/application/users/' . $remotePterodactylId); + } catch (Exception $cleanupException) { + logger()->warning('Failed to clean up remote Pterodactyl user after API create failure.', [ + 'pterodactyl_id' => $remotePterodactylId, + 'error' => $cleanupException->getMessage(), + ]); + } + } - throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $e->getMessage(), - 'pterodactyl_error_status' => $e->getCode(), + if ($e instanceof ValidationException) { + throw $e; + } + + logger()->warning('Failed to create user via API.', [ + 'email_sha256' => $this->hashEmail($data['email'] ?? null), + 'error' => $e->getMessage(), ]); + + throw $this->pterodactylValidationException(__('Failed to create the user on Pterodactyl.')); }; } @@ -334,6 +433,8 @@ public function store(CreateUserRequest $request, UserSettings $userSettings) */ public function destroy(DeleteUserRequest $request, User $user) { + $this->ensureCanAccessUser($request, $user); + $data = $request->validated(); $logMessage = sprintf("The user %s (ID: %d) was deleted via API", $user->name, $user->id); @@ -358,23 +459,128 @@ public function destroy(DeleteUserRequest $request, User $user) */ private function incrementReferralUserCredits(User $user, mixed $data) { - if (!isset($data['referral_code'])) return; + if (!isset($data['referral_code'])) { + return; + } + + $refCode = (string) $data['referral_code']; + + DB::transaction(function () use ($user, $refCode): void { + $refUser = User::query() + ->where('referral_code', $refCode) + ->lockForUpdate() + ->first(); + + if ($refUser === null || $refUser->id === $user->id) { + return; + } - $ref_code = $data['referral_code']; - $ref_user = User::query()->where('referral_code', $ref_code)->first(); + $alreadyLinked = DB::table('user_referrals') + ->where('registered_user_id', $user->id) + ->exists(); - if ($ref_user) { - if ($this->referralSettings->mode == 'sign-up' || $this->referralSettings->mode == 'both') { - $ref_user->increment('credits', $this->referralSettings->reward); - $ref_user->notify(new ReferralNotification($user)); + if ($alreadyLinked) { + return; } DB::table('user_referrals')->insert([ - 'referral_id' => $ref_user->id, + 'referral_id' => $refUser->id, 'registered_user_id' => $user->id, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]); + + if ($this->referralSettings->mode === 'sign-up' || $this->referralSettings->mode === 'both') { + $refUser->increment('credits', $this->referralSettings->reward); + + DB::afterCommit(static function () use ($refUser, $user): void { + $refUser->notify(new ReferralNotification($user)); + }); + } + }); + } + + private function pterodactyl(): PterodactylClient + { + if ($this->pterodactyl === null) { + $this->pterodactyl = new PterodactylClient(app(PterodactylSettings::class)); + } + + return $this->pterodactyl; + } + + private function allowedIncludes(Request $request): array + { + $includes = self::ALLOWED_INCLUDES; + + if ($this->canViewSensitiveUserFields($request)) { + $includes[] = 'discordUser'; + } + + return $includes; + } + + private function allowedFilters(Request $request): array + { + $filters = self::ALLOWED_FILTERS; + + if ($this->canViewSensitiveUserFields($request)) { + $filters[] = 'email'; + $filters[] = 'pterodactyl_id'; + } + + return $filters; + } + + private function canViewSensitiveUserFields(Request $request): bool + { + /** @var ApplicationApi|null $apiToken */ + $apiToken = $request->attributes->get('apiToken'); + + return $apiToken?->hasAbility(ApplicationApi::ABILITY_USERS_SENSITIVE) ?? false; + } + + private function ensureApiRoleIsAssignable(int $roleId): void + { + $role = Role::query()->with('permissions')->findOrFail($roleId); + + if ($this->roleProvidesAdminAreaAccess($role)) { + throw ValidationException::withMessages([ + 'role_id' => [__('Administrative roles cannot be assigned via API.')], + ]); + } + } + + private function roleProvidesAdminAreaAccess(Role $role): bool + { + if ((int) $role->id === \App\Constants\Roles::ADMIN_ROLE_ID || $role->name === 'Admin') { + return true; } + + $permissions = $role->relationLoaded('permissions') + ? $role->permissions + : $role->permissions()->get(); + + return $permissions->pluck('name')->contains( + fn (string $permission) => $permission === '*' + || str_starts_with($permission, 'admin.') + || str_starts_with($permission, 'settings.') + ); + } + + private function hashEmail(?string $email): ?string + { + if ($email === null || $email === '') { + return null; + } + + return hash('sha256', mb_strtolower(trim($email))); + } + + private function pterodactylValidationException(string $message): ValidationException + { + return ValidationException::withMessages([ + 'pterodactyl_error_message' => $message, + ]); } } diff --git a/app/Http/Controllers/Api/VoucherController.php b/app/Http/Controllers/Api/VoucherController.php index 69333fb33..da5e18faf 100644 --- a/app/Http/Controllers/Api/VoucherController.php +++ b/app/Http/Controllers/Api/VoucherController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Http\Controllers\Api\Concerns\InteractsWithScopedApiTokens; use App\Models\Voucher; use App\Http\Resources\VoucherResource; use App\Http\Controllers\Controller; @@ -13,6 +14,8 @@ class VoucherController extends Controller { + use InteractsWithScopedApiTokens; + const ALLOWED_INCLUDES = ['users']; const ALLOWED_FILTERS = ['code', 'memo', 'credits', 'uses']; @@ -24,10 +27,12 @@ class VoucherController extends Controller */ public function index(Request $request) { + $this->ensureGlobalToken($request); + $vouchers = QueryBuilder::for(Voucher::class) ->allowedIncludes(self::ALLOWED_INCLUDES) ->allowedFilters(self::ALLOWED_FILTERS) - ->paginate($request->input('per_page') ?? 50); + ->paginate($this->perPage($request)); return VoucherResource::collection($vouchers); } @@ -40,6 +45,8 @@ public function index(Request $request) */ public function store(CreateVoucherRequest $request) { + $this->ensureGlobalToken($request); + $data = $request->validated(); $voucher = Voucher::create($data); @@ -58,11 +65,13 @@ public function store(CreateVoucherRequest $request) * * @throws ModelNotFoundException */ - public function show(Request $request, int $voucher) + public function show(Request $request, Voucher $voucher) { + $this->ensureGlobalToken($request); + $voucherQuery = QueryBuilder::for(Voucher::class) ->allowedIncludes(self::ALLOWED_INCLUDES) - ->where('id', $voucher) + ->whereKey($voucher->id) ->firstOrFail(); return VoucherResource::make($voucherQuery); @@ -79,6 +88,8 @@ public function show(Request $request, int $voucher) */ public function update(UpdateVoucherRequest $request, Voucher $voucher) { + $this->ensureGlobalToken($request); + $data = $request->validated(); $voucher->update($data); @@ -97,6 +108,8 @@ public function update(UpdateVoucherRequest $request, Voucher $voucher) */ public function destroy(Request $request, Voucher $voucher) { + $this->ensureGlobalToken($request); + $voucher->delete(); return response()->noContent(); diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 6643533e0..3478ed31a 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers\Auth; +use App\Constants\Roles; use App\Http\Controllers\Controller; +use App\Models\Role; use App\Models\User; use App\Providers\RouteServiceProvider; use App\Traits\Referral; @@ -16,11 +18,11 @@ use App\Actions\ProcessReferralAction; use Coderflex\LaravelTurnstile\Rules\TurnstileCheck; use Illuminate\Foundation\Auth\RegistersUsers; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; -use Spatie\Permission\Models\Role; class RegisterController extends Controller { @@ -126,47 +128,88 @@ protected function validator(array $data) */ protected function create(array $data) { - $response = $this->pterodactylClient->application->post('/application/users', [ - 'external_id' => null, - 'username' => $data['name'], - 'email' => $data['email'], - 'first_name' => $data['name'], - 'last_name' => $data['name'], - 'password' => $data['password'], - 'root_admin' => false, - 'language' => 'en', - ]); - - if ($response->failed()) { - Log::error('Pterodactyl Registration Error: ' . ($response->json()['errors'][0]['detail'] ?? 'Unknown error')); - throw ValidationException::withMessages([ - 'ptero_registration_error' => [__('Failed to create account on Pterodactyl. Please contact Support!')], + $remotePterodactylId = null; + + try { + return DB::transaction(function () use ($data, &$remotePterodactylId): User { + $user = User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'credits' => $this->userSettings->initial_credits, + 'server_limit' => $this->userSettings->initial_server_limit, + 'password' => Hash::make($data['password']), + 'referral_code' => $this->createReferralCode(), + ]); + + $response = $this->pterodactylClient->application->post('/application/users', [ + 'external_id' => (string) $user->id, + 'username' => $data['name'], + 'email' => $data['email'], + 'first_name' => $data['name'], + 'last_name' => $data['name'], + 'password' => $data['password'], + 'root_admin' => false, + 'language' => 'en', + ]); + + if ($response->failed()) { + Log::error('Pterodactyl Registration Error: ' . ($response->json()['errors'][0]['detail'] ?? 'Unknown error')); + + throw ValidationException::withMessages([ + 'ptero_registration_error' => [__('Failed to create account on Pterodactyl. Please contact Support!')], + ]); + } + + $remotePterodactylId = data_get($response->json(), 'attributes.id'); + + if (! is_int($remotePterodactylId) && ! ctype_digit((string) $remotePterodactylId)) { + Log::error('Pterodactyl Registration Error: Missing user ID in response'); + + throw ValidationException::withMessages([ + 'ptero_registration_error' => [__('Failed to create account on Pterodactyl. Please contact Support!')], + ]); + } + + $user->update([ + 'pterodactyl_id' => (int) $remotePterodactylId, + ]); + + $clientRole = Role::query() + ->where('name', 'Client') + ->orWhere('id', Roles::CLIENT_ROLE_ID) + ->firstOrFail(); + + $user->syncRoles($clientRole); + + if (! empty($data['referral_code'])) { + $this->processReferralAction->execute($user, $data['referral_code'], true); + } + + return $user; + }); + } catch (\Throwable $exception) { + if ($remotePterodactylId) { + try { + $this->pterodactylClient->application->delete('/application/users/' . $remotePterodactylId); + } catch (\Throwable $cleanupException) { + Log::warning('Failed to roll back remote Pterodactyl user after registration error.', [ + 'pterodactyl_id' => $remotePterodactylId, + 'error' => $cleanupException->getMessage(), + ]); + } + } + + if ($exception instanceof ValidationException) { + throw $exception; + } + + Log::error('Registration failed unexpectedly', [ + 'error' => $exception->getMessage(), ]); - } - if (!isset($response->json()['attributes']['id'])) { - Log::error('Pterodactyl Registration Error: Missing user ID in response'); throw ValidationException::withMessages([ 'ptero_registration_error' => [__('Failed to create account on Pterodactyl. Please contact Support!')], ]); } - - $user = User::create([ - 'name' => $data['name'], - 'email' => $data['email'], - 'credits' => $this->userSettings->initial_credits, - 'server_limit' => $this->userSettings->initial_server_limit, - 'password' => Hash::make($data['password']), - 'referral_code' => $this->createReferralCode(), - 'pterodactyl_id' => $response->json()['attributes']['id'], - ]); - - $user->syncRoles(Role::findById(4)); - - if (!empty($data['referral_code'])) { - $this->processReferralAction->execute($user, $data['referral_code'], true); - } - - return $user; } } diff --git a/app/Http/Controllers/Auth/SocialiteController.php b/app/Http/Controllers/Auth/SocialiteController.php index 5e88d8a62..2d6592afa 100644 --- a/app/Http/Controllers/Auth/SocialiteController.php +++ b/app/Http/Controllers/Auth/SocialiteController.php @@ -7,39 +7,71 @@ use App\Models\User; use App\Settings\DiscordSettings; use App\Settings\UserSettings; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Laravel\Socialite\Facades\Socialite; use Exception; class SocialiteController extends Controller { + private function discordDriver(?string $redirectUrl = null) + { + return Socialite::driver('discord')->redirectUrl($redirectUrl ?? route('auth.callback')); + } + public function redirect(DiscordSettings $discord_settings) { $scopes = !empty($discord_settings->bot_token) && !empty($discord_settings->guild_id) ? ['guilds.join'] : []; - return ( Socialite::driver('discord') + return ($this->discordDriver() ->scopes($scopes) ->redirect()); } - public function callback(DiscordSettings $discord_settings, UserSettings $user_settings) + public function callback(Request $request, DiscordSettings $discord_settings, UserSettings $user_settings) { + $redirectRoute = Auth::guest() ? 'login' : 'profile.index'; + + if ($request->filled('error')) { + return redirect()->route($redirectRoute)->with('error', __('Discord authorization was denied.')); + } + + if (str_contains((string) $request->query('scope', ''), 'openid')) { + return redirect()->route($redirectRoute)->with( + 'error', + __('Unexpected OAuth callback. Check that your Discord redirect URI points to the Discord callback route.') + ); + } + if (Auth::guest()) { - return abort(500); + return redirect()->route('login')->with('error', __('Please sign in before linking your Discord account.')); } /** @var User $user */ $user = Auth::user(); - $discord = Socialite::driver('discord')->user(); + try { + $discord = $this->discordDriver($request->url())->user(); + } catch (\Throwable $e) { + logger()->warning('Discord callback failed', [ + 'message' => $e->getMessage(), + 'scope' => $request->query('scope'), + 'has_code' => $request->filled('code'), + ]); + + return redirect()->route('profile.index')->with( + 'error', + __('Failed to validate the Discord callback. Check the configured Discord redirect URI and try again.') + ); + } + $botToken = $discord_settings->bot_token; $guildId = $discord_settings->guild_id; $roleId = $discord_settings->role_id; + $isNewLink = is_null($user->discordUser); - //save / update discord_users - - //check if discord account is already linked to an cpgg account - if (is_null($user->discordUser)) { + if ($isNewLink) { $discordLinked = DiscordUser::where('id', '=', $discord->id)->first(); if ($discordLinked !== null) { return redirect()->route('profile.index')->with( @@ -47,21 +79,8 @@ public function callback(DiscordSettings $discord_settings, UserSettings $user_s 'Discord account already linked!' ); } - - //create discord user in db - DiscordUser::create(array_merge($discord->user, ['user_id' => Auth::user()->id])); - $user->refresh(); - - //update user - Auth::user()->increment('credits', $user_settings->credits_reward_after_verify_discord); - Auth::user()->increment('server_limit', $user_settings->server_limit_increment_after_verify_discord); - Auth::user()->update(['discord_verified_at' => now()]); - } else { - $user->discordUser->update($discord->user); } - //force user into discord server - //TODO Add event on failure, to notify ppl involved if (! empty($guildId) && ! empty($botToken)) { try { $response = Http::withHeaders( @@ -69,7 +88,7 @@ public function callback(DiscordSettings $discord_settings, UserSettings $user_s 'Authorization' => 'Bot '. $botToken, 'Content-Type' => 'application/json', ] - )->put( + )->timeout(30)->connectTimeout(10)->put( "https://discord.com/api/guilds/{$guildId}/members/{$discord->id}", ['access_token' => $discord->token] ); @@ -80,10 +99,6 @@ public function callback(DiscordSettings $discord_settings, UserSettings $user_s ($response->json('message') ?? 'Unknown error') ); } - - if (!empty($roleId)) { - $user->discordUser->addOrRemoveRole('add', $roleId); - } } catch (Exception $e) { logger()->error($e->getMessage()); @@ -94,6 +109,23 @@ public function callback(DiscordSettings $discord_settings, UserSettings $user_s } } + DB::transaction(function () use ($user, $discord, $user_settings, $isNewLink) { + if ($isNewLink) { + DiscordUser::create(array_merge($discord->user, ['user_id' => $user->id])); + $user->increment('credits', $user_settings->credits_reward_after_verify_discord); + $user->increment('server_limit', $user_settings->server_limit_increment_after_verify_discord); + } else { + $user->discordUser->update($discord->user); + } + + $user->update(['discord_verified_at' => now()]); + }); + + if (! empty($roleId)) { + $user->refresh(); + $user->discordUser?->addOrRemoveRole('add', $roleId); + } + return redirect()->route('profile.index')->with( 'success', 'Discord account linked!' diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 4cc79cd97..a455d60ad 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; +use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Facades\Auth; @@ -61,4 +62,9 @@ public function canAny(iterable $permission): bool return $user->canAny($permission); } + + protected function perPage(Request $request, int $default = 50, int $max = 100): int + { + return max(1, min((int) $request->input('per_page', $default), $max)); + } } diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index f3c35ee1d..9878c042a 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -19,10 +19,11 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; -use App\Models\Role; class ProductController extends Controller { + private const CREATE_PERMISSION = 'user.server.create'; + private $pterodactyl; public function __construct(PterodactylSettings $ptero_settings) @@ -37,10 +38,12 @@ public function __construct(PterodactylSettings $ptero_settings) * @param Egg $egg * @return Collection|JsonResponse */ - public function getNodesBasedOnEgg(Request $request, Egg $egg) + public function getNodesBasedOnEgg(Request $request, ?Egg $egg = null) { - if (is_null($egg->id)) { - return response()->json('Egg ID is required', '400'); + $this->checkPermission(self::CREATE_PERMISSION); + + if ($egg === null) { + return response()->json('Egg ID is required', 400); } //get products that include this egg @@ -72,8 +75,14 @@ public function getNodesBasedOnEgg(Request $request, Egg $egg) * @param Egg $egg * @return Collection|JsonResponse */ - public function getLocationsBasedOnEgg(Request $request, Egg $egg) + public function getLocationsBasedOnEgg(Request $request, ?Egg $egg = null) { + $this->checkPermission(self::CREATE_PERMISSION); + + if ($egg === null) { + return response()->json('Egg ID is required', 400); + } + $nodes = $this->getNodesBasedOnEgg($request, $egg); foreach ($nodes as $key => $node) { $pteroNode = $this->pterodactyl->getNode($node->id); @@ -105,7 +114,7 @@ public function getLocationsBasedOnEgg(Request $request, Egg $egg) // Rate limit the node full notification to 1 attempt per 30 minutes RateLimiter::attempt( key: 'nodes-full-warning', - maxAttempts: 2, + maxAttempts: 1, callback: function() { // get admin role and check users $users = User::permission("errors.view")->get(); @@ -117,7 +126,7 @@ public function getLocationsBasedOnEgg(Request $request, Egg $egg) Log::warning("There are no nodes at all - Users couldnt be notified"); } }, - decaySeconds: 5 + decaySeconds: 1800 ); } @@ -129,9 +138,11 @@ public function getLocationsBasedOnEgg(Request $request, Egg $egg) * @param Egg $egg * @return Collection|JsonResponse */ - public function getProductsBasedOnLocation(Egg $egg, int $location) + public function getProductsBasedOnLocation(?Egg $egg = null, ?int $location = null) { - if (is_null($egg->id) || is_null($location)) { + $this->checkPermission(self::CREATE_PERMISSION); + + if ($egg === null || $location === null) { return response()->json('Location and Egg ID are required', 400); } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php index c68f2cd03..c22f43c7e 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/ProfileController.php @@ -34,8 +34,7 @@ public function index(UserSettings $user_settings, DiscordSettings $discord_sett 'credits_reward_after_verify_discord' => $user_settings->credits_reward_after_verify_discord, 'force_email_verification' => $user_settings->force_email_verification, 'force_discord_verification' => $user_settings->force_discord_verification, - 'discord_client_id' => $discord_settings->client_id, - 'discord_client_secret' => $discord_settings->client_secret, + 'discord_link_enabled' => !empty($discord_settings->client_id) && !empty($discord_settings->client_secret), 'referral_enabled' => $referral_settings->enabled ]); } @@ -59,94 +58,77 @@ public function update(Request $request, int $id) { //prevent other users from editing a user if ($id != Auth::user()->id) { - dd(401); + abort(403); } $user = User::findOrFail($id); - //update password if necessary - if (!is_null($request->input('new_password'))) { - - //validate password request - $request->validate([ - 'current_password' => [ - 'required', - function ($attribute, $value, $fail) use ($user) { - if (!Hash::check($value, $user->password)) { - $fail('The ' . $attribute . ' is invalid.'); - } - }, - ], - 'new_password' => 'required|string|min:8', - 'new_password_confirmation' => 'required|same:new_password', - ]); + $validated = $request->validate([ + 'name' => 'required|min:4|max:30|alpha_num|unique:users,name,' . $id . ',id', + 'email' => 'required|email|max:64|unique:users,email,' . $id . ',id', + 'avatar' => 'nullable', + 'current_password' => [ + 'required_with:new_password', + function ($attribute, $value, $fail) use ($user) { + if (! empty($value) && ! Hash::check($value, $user->password)) { + $fail('The ' . $attribute . ' is invalid.'); + } + }, + ], + 'new_password' => 'nullable|string|min:8', + 'new_password_confirmation' => 'nullable|same:new_password', + ]); - //Update Users Password on Pterodactyl - //Username,Mail,First and Lastname are required aswell - $response = $this->pterodactyl->application->patch('/application/users/' . $user->pterodactyl_id, [ - 'password' => $request->input('new_password'), - 'username' => $request->input('name'), - 'first_name' => $request->input('name'), - 'last_name' => $request->input('name'), - 'email' => $request->input('email'), + $avatarValue = null; - ]); - if ($response->failed()) { + if (!is_null($validated['avatar'] ?? null)) { + $avatar = json_decode($validated['avatar']); + + if (! is_object($avatar) || ! isset($avatar->input->size, $avatar->output->image)) { throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $response->toException()->getMessage(), - 'pterodactyl_error_status' => $response->toException()->getCode(), + 'avatar' => __('The avatar payload is invalid.'), ]); } - //update password - $user->update([ - 'password' => Hash::make($request->input('new_password')), - ]); - } - - //validate request - $request->validate([ - 'name' => 'required|min:4|max:30|alpha_num|unique:users,name,' . $id . ',id', - 'email' => 'required|email|max:64|unique:users,email,' . $id . ',id', - 'avatar' => 'nullable', - ]); - //update avatar - if (!is_null($request->input('avatar'))) { - $avatar = json_decode($request->input('avatar')); if ($avatar->input->size > 3000000) { - abort(500); + throw ValidationException::withMessages([ + 'avatar' => __('The avatar may not be greater than 3 MB.'), + ]); } - $user->update([ - 'avatar' => $avatar->output->image, - ]); - } else { - $user->update([ - 'avatar' => null, - ]); + $avatarValue = $avatar->output->image; } //update name and email on Pterodactyl - $response = $this->pterodactyl->application->patch('/application/users/' . $user->pterodactyl_id, [ - 'username' => $request->input('name'), - 'first_name' => $request->input('name'), - 'last_name' => $request->input('name'), - 'email' => $request->input('email'), - ]); + $pterodactylPayload = array_filter([ + 'username' => $validated['name'], + 'first_name' => $validated['name'], + 'last_name' => $validated['name'], + 'email' => $validated['email'], + 'password' => $validated['new_password'] ?? null, + ], static fn ($value) => $value !== null); + + $response = $this->pterodactyl->application->patch('/application/users/' . $user->pterodactyl_id, $pterodactylPayload); if ($response->failed()) { + logger()->warning('Failed to update profile in Pterodactyl.', [ + 'user_id' => $user->id, + 'status' => $response->status(), + ]); + throw ValidationException::withMessages([ - 'pterodactyl_error_message' => $response->toException()->getMessage(), - 'pterodactyl_error_status' => $response->toException()->getCode(), + 'pterodactyl_error_message' => __('Failed to update your account on Pterodactyl. Please try again later.'), ]); } //update name and email $user->update([ - 'name' => $request->input('name'), - 'email' => $request->input('email'), + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => ! empty($validated['new_password']) ? Hash::make($validated['new_password']) : $user->password, + 'avatar' => $avatarValue, ]); - if ($request->input('email') != Auth::user()->email) { + if ($validated['email'] != Auth::user()->email) { $user->reVerifyEmail(); $user->sendEmailVerificationNotification(); } diff --git a/app/Http/Controllers/ServerController.php b/app/Http/Controllers/ServerController.php index 0e42246ac..10c43e391 100644 --- a/app/Http/Controllers/ServerController.php +++ b/app/Http/Controllers/ServerController.php @@ -9,9 +9,11 @@ use App\Models\Product; use App\Models\Server; use App\Models\User; -use App\Notifications\ServerCreationError; +use App\Rules\EggBelongsToProduct; +use App\Rules\ValidateEggVariables; +use App\Services\ServerCreationService; +use App\Services\ServerUpgradeService; use App\Settings\DiscordSettings; -use Carbon\Carbon; use App\Settings\UserSettings; use App\Settings\ServerSettings; use App\Settings\PterodactylSettings; @@ -20,14 +22,13 @@ use App\Settings\GeneralSettings; use Exception; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Http\Client\Response; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Request as FacadesRequest; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; use Illuminate\Validation\Rules\Enum; @@ -35,15 +36,6 @@ class ServerController extends Controller { private const CREATE_PERMISSION = 'user.server.create'; private const UPGRADE_PERMISSION = 'user.server.upgrade'; - private const BILLING_PERIODS = [ - 'hourly' => 3600, - 'daily' => 86400, - 'weekly' => 604800, - 'monthly' => 2592000, - 'quarterly' => 7776000, - 'half-annually' => 15552000, - 'annually' => 31104000 - ]; private PterodactylClient $pterodactyl; private PterodactylSettings $pteroSettings; @@ -51,13 +43,17 @@ class ServerController extends Controller private ServerSettings $serverSettings; private UserSettings $userSettings; private DiscordSettings $discordSettings; + private ServerCreationService $serverCreationService; + private ServerUpgradeService $serverUpgradeService; public function __construct( PterodactylSettings $pteroSettings, GeneralSettings $generalSettings, ServerSettings $serverSettings, UserSettings $userSettings, - DiscordSettings $discordSettings + DiscordSettings $discordSettings, + ServerCreationService $serverCreationService, + ServerUpgradeService $serverUpgradeService ) { $this->pteroSettings = $pteroSettings; $this->pterodactyl = new PterodactylClient($pteroSettings); @@ -65,6 +61,8 @@ public function __construct( $this->serverSettings = $serverSettings; $this->userSettings = $userSettings; $this->discordSettings = $discordSettings; + $this->serverCreationService = $serverCreationService; + $this->serverUpgradeService = $serverUpgradeService; } public function index(): \Illuminate\View\View @@ -83,7 +81,7 @@ public function create(): \Illuminate\View\View|RedirectResponse { $this->checkPermission(self::CREATE_PERMISSION); - $validationResult = $this->validateServerCreation(app(Request::class)); + $validationResult = $this->validateServerCreation(); if ($validationResult) { return $validationResult; } @@ -112,6 +110,15 @@ public function create(): \Illuminate\View\View|RedirectResponse public function store(Request $request): RedirectResponse { + $this->checkPermission(self::CREATE_PERMISSION); + + $validated = $this->validateServerCreationPayload($request); + $product = Product::findOrFail($validated['product_id']); + $validationResult = $this->validateServerCreation($product, (int) $validated['location_id']); + if ($validationResult) { + return $validationResult; + } + $lockKey = 'server_create_lock_' . Auth::id(); if (Cache::has($lockKey)) { return redirect()->route('servers.index') @@ -119,23 +126,17 @@ public function store(Request $request): RedirectResponse } Cache::put($lockKey, true, 5); - $validationResult = $this->validateServerCreation($request); - if ($validationResult) return $validationResult; - - $request->validate([ - 'name' => 'required|max:191', - 'location' => 'required|exists:locations,id', - 'egg' => 'required|exists:eggs,id', - 'product' => 'required|exists:products,id', - 'egg_variables' => 'nullable|string', - 'billing_priority' => ['nullable', new Enum(BillingPriority::class)], - ]); - - $server = $this->createServer($request); + try { + $server = $this->createServer($request->user(), $product, $validated); + } catch (Exception $e) { + Log::error('Server creation failed', [ + 'user_id' => $request->user()->id, + 'product_id' => $validated['product_id'], + 'error' => $e->getMessage(), + ]); - if (!$server) { return redirect()->route('servers.index') - ->with('error', __('Server creation failed')); + ->with('error', $this->userFacingServerError($e, __('Server creation failed. Please try again later.'))); } $this->handlePostCreation($request->user(), $server); @@ -144,7 +145,7 @@ public function store(Request $request): RedirectResponse ->with('success', __('Server created')); } - private function validateServerCreation(Request $request): ?RedirectResponse + private function validateServerCreation(?Product $product = null, ?int $locationId = null): ?RedirectResponse { $user = Auth::user(); @@ -153,10 +154,8 @@ private function validateServerCreation(Request $request): ?RedirectResponse ->with('error', __('Server limit reached!')); } - if ($request->has('product')) { - $product = Product::findOrFail($request->input('product')); - - $validationResult = $this->validateProductRequirements($product, $request); + if ($product !== null && $locationId !== null) { + $validationResult = $this->validateProductRequirements($product, $locationId); if ($validationResult !== true) { return redirect()->route('servers.index') ->with('error', $validationResult); @@ -171,10 +170,9 @@ private function validateServerCreation(Request $request): ?RedirectResponse return null; } - private function validateProductRequirements(Product $product, Request $request): string|bool + private function validateProductRequirements(Product $product, int $locationId): string|bool { - $location = $request->input('location'); - $availableNode = $this->findAvailableNode($location, $product); + $availableNode = $this->findAvailableNode($locationId, $product); if (!$availableNode) { return __("The chosen location doesn't have the required memory or disk left to allocate this product."); @@ -268,62 +266,56 @@ private function updateServerInfo(Server $server, array $serverInfo): void } } - private function createServer(Request $request): ?Server + private function validateServerCreationPayload(Request $request): array { - $product = Product::findOrFail($request->input('product')); - $egg = $product->eggs()->findOrFail($request->input('egg')); - $node = $this->findAvailableNode($request->input('location'), $product); - - if (!$node) return null; + $eggVariables = json_decode($request->input('egg_variables', '[]'), true); - $server = $request->user()->servers()->create([ - 'name' => $request->input('name'), - 'product_id' => $product->id, - 'last_billed' => Carbon::now(), - 'billing_priority' => $request->input('billing_priority', $product->default_billing_priority), - ]); - - $allocationId = $this->pterodactyl->getFreeAllocationId($node); - if (!$allocationId) { - Log::error('No AllocationID found.', [ - 'server_id' => $server->id, - 'node_id' => $node->id, + if (! is_array($eggVariables)) { + throw ValidationException::withMessages([ + 'egg_variables' => __('The deployment variables payload is invalid.'), ]); - $server->delete(); - return null; } - $response = $this->pterodactyl->createServer($server, $egg, $allocationId, $request->input('egg_variables')); - if ($response->failed()) { - Log::error('Failed to create server on Pterodactyl', [ - 'server_id' => $server->id, - 'status' => $response->status(), - 'error' => $response->json() - ]); - $server->delete(); - return null; - } + return Validator::make([ + 'name' => $request->input('name'), + 'description' => $request->input('description'), + 'location_id' => $request->input('location'), + 'egg_id' => $request->input('egg'), + 'product_id' => $request->input('product'), + 'egg_variables' => $eggVariables, + 'billing_priority' => $request->input('billing_priority'), + ], [ + 'name' => 'required|string|max:191', + 'description' => 'nullable|string|max:255', + 'location_id' => 'required|integer|exists:locations,id', + 'egg_id' => ['required', 'integer', 'exists:eggs,id', new EggBelongsToProduct, new ValidateEggVariables], + 'product_id' => 'required|exists:products,id', + 'egg_variables' => 'nullable|array', + 'billing_priority' => ['nullable', new Enum(BillingPriority::class)], + ])->validate(); + } - $serverAttributes = $response->json()['attributes']; - $server->update([ - 'pterodactyl_id' => $serverAttributes['id'], - 'identifier' => $serverAttributes['identifier'] + private function createServer(User $user, Product $product, array $validated): Server + { + return $this->serverCreationService->handle($user, $product, [ + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'product_id' => $product->id, + 'egg_id' => (int) $validated['egg_id'], + 'location_id' => (int) $validated['location_id'], + 'egg_variables' => $validated['egg_variables'] ?? [], + 'billing_priority' => $validated['billing_priority'] ?? $product->default_billing_priority, ]); - - return $server; } private function handlePostCreation(User $user, Server $server): void { - logger('Product Price: ' . $server->product->price); - - $user->decrement('credits', $server->product->price); Cache::forget('user_credits_left:' . $user->id); try { if ($this->discordSettings->role_for_active_clients && $user->discordUser && - $user->servers->count() >= 1 + $user->activeServers()->exists() ) { $user->discordUser->addOrRemoveRole( 'add', @@ -360,7 +352,7 @@ public function destroy(Server $server): RedirectResponse ]); return redirect()->route('servers.index') - ->with('error', __('Server removal failed: ') . $e->getMessage()); + ->with('error', $this->userFacingServerError($e, __('Server removal failed. Please try again later.'))); } } @@ -368,7 +360,7 @@ private function handleServerDeletion(Server $server): void { if ($this->discordSettings->role_for_active_clients) { $user = User::findOrFail($server->user_id); - if ($user->discordUser && $user->servers->count() <= 1) { + if ($user->discordUser && ! $user->activeServers()->whereKeyNot($server->id)->exists()) { $user->discordUser->addOrRemoveRole( 'remove', $this->discordSettings->role_id_for_active_clients @@ -392,7 +384,7 @@ public function cancel(Server $server): RedirectResponse ->with('success', __('Server canceled')); } catch (Exception $e) { return redirect()->route('servers.index') - ->with('error', __('Server cancellation failed: ') . $e->getMessage()); + ->with('error', __('Server cancellation failed. Please try again later.')); } } @@ -466,7 +458,6 @@ public function upgrade(Server $server, Request $request): RedirectResponse } $user = Auth::user(); - $oldProduct = Product::find($server->product->id); $newProduct = Product::find($request->product_upgrade); if (!$newProduct) { @@ -474,25 +465,20 @@ public function upgrade(Server $server, Request $request): RedirectResponse ->with('error', __('Selected product not found')); } - if (!$this->validateUpgrade($server, $oldProduct, $newProduct)) { - return redirect()->route('servers.show', ['server' => $server->id]) - ->with('error', __('Insufficient resources or credits for upgrade')); - } - try { - $this->processUpgrade($server, $oldProduct, $newProduct, $user); + $this->serverUpgradeService->handle($user, $newProduct, $server); return redirect()->route('servers.show', ['server' => $server->id]) ->with('success', __('Server Successfully Upgraded')); } catch (Exception $e) { Log::error('Server upgrade failed', [ 'server_id' => $server->id, - 'old_product' => $oldProduct->id, + 'old_product' => $server->product->id, 'new_product' => $newProduct->id, 'error' => $e->getMessage() ]); return redirect()->route('servers.show', ['server' => $server->id]) - ->with('error', __('Upgrade failed: ') . $e->getMessage()); + ->with('error', $this->userFacingServerError($e, __('Upgrade failed. Please try again later.'))); } } @@ -513,79 +499,6 @@ public function updateBillingPriority(Server $server, Request $request): Redirec ->with('success', __('Billing priority updated successfully')); } - private function validateUpgrade(Server $server, Product $oldProduct, Product $newProduct): bool - { - $user = Auth::user(); - if (!$server->product) { - return false; - } - - $serverInfo = $this->pterodactyl->getServerAttributes($server->pterodactyl_id); - if (!$serverInfo) { - return false; - } - - $nodeId = $serverInfo['relationships']['node']['attributes']['id']; - $node = Node::findOrFail($nodeId); - - // Check node resources - $requireMemory = $newProduct->memory - $oldProduct->memory; - $requireDisk = $newProduct->disk - $oldProduct->disk; - if (!$this->pterodactyl->checkNodeResources($node, $requireMemory, $requireDisk)) { - return false; - } - - // Check if user has enough credits after refund - $refundAmount = $this->calculateRefund($server, $oldProduct); - if ($user->credits < ($newProduct->price - $refundAmount)) { - return false; - } - - return true; - } - - private function processUpgrade(Server $server, Product $oldProduct, Product $newProduct, User $user): void - { - $server->allocation = $this->pterodactyl->getServerAttributes($server->pterodactyl_id)['allocation']; - - $response = $this->pterodactyl->updateServer($server, $newProduct); - if ($response->failed()) { - throw new Exception("Failed to update server on Pterodactyl"); - } - - $restartResponse = $this->pterodactyl->powerAction($server, 'restart'); - if ($restartResponse->failed()) { - throw new Exception('Could not restart the server: ' . $restartResponse->json()['errors'][0]['detail']); - } - - // Calculate refund - $refund = $this->calculateRefund($server, $oldProduct); - if ($refund > 0) { - $user->increment('credits', $refund); - } - - // Update server - unset($server->allocation); - $server->update([ - 'product_id' => $newProduct->id, - 'updated_at' => now(), - 'last_billed' => now(), - 'canceled' => null, - ]); - - // Charge for new product - $user->decrement('credits', $newProduct->price); - } - - private function calculateRefund(Server $server, Product $oldProduct): float - { - $billingPeriod = $oldProduct->billing_period; - $billingPeriodSeconds = self::BILLING_PERIODS[$billingPeriod]; - $timeUsed = now()->diffInSeconds($server->last_billed, true); - - return $oldProduct->price - ($oldProduct->price * ($timeUsed / $billingPeriodSeconds)); - } - private function findAvailableNode(string $locationId, Product $product): ?Node { $nodes = Node::where('location_id', $locationId) @@ -601,14 +514,35 @@ private function findAvailableNode(string $locationId, Product $product): ?Node public function validateDeploymentVariables(Request $request) { - $variables = $request->input('variables'); + $this->checkPermission(self::CREATE_PERMISSION); + + $data = $request->validate([ + 'egg_id' => 'required|integer|exists:eggs,id', + 'variables' => 'required|array', + 'variables.*.env_variable' => 'required|string', + 'variables.*.filled_value' => 'nullable', + 'variables.*.name' => 'required|string', + ]); + + $variables = $data['variables']; + $egg = Egg::query()->findOrFail($data['egg_id']); + $eggEnvironment = collect($egg->environment ?? []) + ->filter(fn ($variable) => is_array($variable) && ! empty($variable['env_variable'])) + ->keyBy('env_variable'); $errors = []; foreach ($variables as $variable) { - $rules = $variable['rules']; $envVariable = $variable['env_variable']; $filledValue = $variable['filled_value']; + $eggVariable = $eggEnvironment->get($envVariable); + + if (! is_array($eggVariable) || empty($eggVariable['rules'])) { + $errors[$envVariable] = [__('The selected deployment variable is invalid.')]; + continue; + } + + $rules = $eggVariable['rules']; $validator = Validator::make( [$envVariable => $filledValue], @@ -635,4 +569,29 @@ public function validateDeploymentVariables(Request $request) 'variables' => $variables, ]); } + + private function userFacingServerError(Exception $exception, string $defaultMessage): string + { + $message = $exception->getMessage(); + $safeMessages = [ + 'Server limit reached for this product.', + 'Server limit reached for this user and product combination.', + 'User must verify their email before creating a server.', + 'User must link their Discord account before creating a server.', + 'Server creation is currently disabled.', + 'No available nodes for this product in the selected location.', + 'No free allocation available on the selected node.', + 'Insufficient resources on the node to upgrade the server.', + 'Insufficient credits to upgrade the server.', + 'Selected egg is not available for this product.', + 'Selected product is not available on the current node.', + 'Selected product is not compatible with the current egg.', + ]; + + if (in_array($message, $safeMessages, true) || str_starts_with($message, 'User do not have the required amount of ')) { + return $message; + } + + return $defaultMessage; + } } diff --git a/app/Http/Controllers/TicketsController.php b/app/Http/Controllers/TicketsController.php index 96fe54865..7fbbdc099 100644 --- a/app/Http/Controllers/TicketsController.php +++ b/app/Http/Controllers/TicketsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use Exception; use App\Models\Server; use App\Models\Ticket; use App\Models\TicketBlacklist; @@ -241,8 +242,6 @@ public function dataTable() ' . method_field('POST') . ' - - '; }) ->rawColumns(['category', 'title', 'status', 'updated_at', "actions"]) diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 131548a35..c0d9548ad 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -3,6 +3,9 @@ namespace App\Http; use App\Http\Middleware\ApiAuthToken; +use App\Http\Middleware\ApiAuditTrail; +use App\Http\Middleware\ApplicationApiScope; +use App\Http\Middleware\CanAccessAdminArea; use App\Http\Middleware\CheckSuspended; use App\Http\Middleware\InstallerLock; use App\Http\Middleware\LastSeen; @@ -18,13 +21,14 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, + \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, + \App\Http\Middleware\SecurityHeaders::class, ]; @@ -76,6 +80,9 @@ class Kernel extends HttpKernel 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, 'canAccessDocsPage' => \App\Http\Middleware\CanAccessDocsPage::class, + 'canAccessAdminArea' => CanAccessAdminArea::class, + 'api.scope' => ApplicationApiScope::class, + 'api.audit' => ApiAuditTrail::class, ]; } diff --git a/app/Http/Middleware/ApiAuditTrail.php b/app/Http/Middleware/ApiAuditTrail.php new file mode 100644 index 000000000..727224266 --- /dev/null +++ b/app/Http/Middleware/ApiAuditTrail.php @@ -0,0 +1,57 @@ +method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return $response; + } + + if ($response->getStatusCode() >= 400) { + return $response; + } + + /** @var ApplicationApi|null $token */ + $token = $request->attributes->get('apiToken'); + if (! $token) { + return $response; + } + + $activity = activity('api')->withProperties([ + 'api_token_id' => $token->id, + 'api_token_owner_user_id' => $token->owner_user_id, + 'route' => $request->route()?->getName(), + 'path' => $request->path(), + 'method' => $request->method(), + 'ip' => $request->ip(), + ]); + + foreach (['user', 'server', 'product', 'voucher', 'role', 'notification'] as $parameter) { + $model = $request->route($parameter); + + if ($model instanceof Model) { + $activity->performedOn($model); + break; + } + } + + $activity->log(sprintf( + 'API %s %s', + $request->method(), + $request->route()?->getName() ?? $request->path() + )); + + return $response; + } +} diff --git a/app/Http/Middleware/ApiAuthToken.php b/app/Http/Middleware/ApiAuthToken.php index 1881230f8..c361a87fb 100644 --- a/app/Http/Middleware/ApiAuthToken.php +++ b/app/Http/Middleware/ApiAuthToken.php @@ -21,13 +21,20 @@ public function handle(Request $request, Closure $next) return response()->json(['message' => 'Missing Authorization header'], 403); } - $token = ApplicationApi::find($request->bearerToken()); + $token = ApplicationApi::findToken($request->bearerToken()); if (is_null($token)) { return response()->json(['message' => 'Invalid Authorization token'], 401); } + if (! $token->isActive()) { + return response()->json(['message' => 'Expired or revoked Authorization token'], 401); + } + + $request->attributes->set('apiToken', $token); + + $response = $next($request); $token->updateLastUsed(); - return $next($request); + return $response; } } diff --git a/app/Http/Middleware/ApplicationApiScope.php b/app/Http/Middleware/ApplicationApiScope.php new file mode 100644 index 000000000..e158a10bc --- /dev/null +++ b/app/Http/Middleware/ApplicationApiScope.php @@ -0,0 +1,33 @@ +attributes->get('apiToken'); + + if (! $token) { + return response()->json(['message' => 'Unauthenticated API token context'], 401); + } + + if (! $token->isActive()) { + return response()->json(['message' => 'Expired or revoked Authorization token'], 401); + } + + if (! $token->hasAnyAbility($abilities)) { + return response()->json(['message' => 'The API token does not have the required scope'], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/CanAccessAdminArea.php b/app/Http/Middleware/CanAccessAdminArea.php new file mode 100644 index 000000000..0504f3e06 --- /dev/null +++ b/app/Http/Middleware/CanAccessAdminArea.php @@ -0,0 +1,36 @@ +user(); + + if (! $user) { + abort(403, __('Unauthorized access to the admin area.')); + } + + if ($user->can('*') || $user->hasRole('Admin')) { + return $next($request); + } + + $canAccessAdminArea = $user->getAllPermissions() + ->pluck('name') + ->contains(fn (string $permission) => str_starts_with($permission, 'admin.') || str_starts_with($permission, 'settings.')); + + if (! $canAccessAdminArea) { + abort(403, __('Unauthorized access to the admin area.')); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/InstallerLock.php b/app/Http/Middleware/InstallerLock.php index f6f09e3b5..de8267f34 100644 --- a/app/Http/Middleware/InstallerLock.php +++ b/app/Http/Middleware/InstallerLock.php @@ -16,9 +16,20 @@ class InstallerLock */ public function handle(Request $request, Closure $next) { - if (!file_exists(base_path()."/install.lock")){ + if (app()->environment('testing')) { + return $next($request); + } + + if (! file_exists(base_path() . "/install.lock")) { + $webInstallerEnabled = filter_var(env('ENABLE_WEB_INSTALLER', false), FILTER_VALIDATE_BOOLEAN); + + if (app()->environment('production') && ! $webInstallerEnabled) { + abort(503, __('The application is not installed and the web installer is disabled in production.')); + } + return redirect('/installer'); } + return $next($request); } } diff --git a/app/Http/Middleware/LastSeen.php b/app/Http/Middleware/LastSeen.php index 2b4985671..d7b1170f2 100644 --- a/app/Http/Middleware/LastSeen.php +++ b/app/Http/Middleware/LastSeen.php @@ -17,10 +17,6 @@ class LastSeen */ public function handle(Request $request, Closure $next) { - if (config('app.env', 'local') == 'local') { - return $next($request); - } - if (! Auth::check()) { return $next($request); } diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 000000000..ee48f1ddc --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,40 @@ +headers->set('X-Frame-Options', 'DENY'); + + // Prevent MIME sniffing + $response->headers->set('X-Content-Type-Options', 'nosniff'); + + // Enable browser XSS protection + $response->headers->set('X-XSS-Protection', '1; mode=block'); + + // Control referrer information + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + + // Enforce HTTPS in production + if ($request->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + } + + // Disable access to features + $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + return $response; + } +} diff --git a/app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php b/app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php index cadbaa537..8c7cc3e11 100644 --- a/app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php +++ b/app/Http/Requests/Api/Notifications/SendToAllUsersNotificationRequest.php @@ -23,8 +23,8 @@ public function rules(): array { return [ 'via' => 'required|in:mail,database,both', - 'title' => 'required|string|min:1', - 'content' => 'required|string|min:1', + 'title' => 'required|string|min:1|max:255', + 'content' => 'required|string|min:1|max:5000', ]; } } diff --git a/app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php b/app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php index 0eb18d425..9a7d5feaa 100644 --- a/app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php +++ b/app/Http/Requests/Api/Notifications/SendToUsersNotificationRequest.php @@ -25,8 +25,8 @@ public function rules(): array 'via' => 'required|in:mail,database,both', 'users' => 'required|array', 'users.*' => 'integer|exists:users,id', - 'title' => 'required|string|min:1', - 'content' => 'required|string|min:1', + 'title' => 'required|string|min:1|max:255', + 'content' => 'required|string|min:1|max:5000', ]; } } diff --git a/app/Http/Requests/Api/Products/CreateProductRequest.php b/app/Http/Requests/Api/Products/CreateProductRequest.php index 3cdbbbfc1..02ceff16b 100644 --- a/app/Http/Requests/Api/Products/CreateProductRequest.php +++ b/app/Http/Requests/Api/Products/CreateProductRequest.php @@ -36,15 +36,15 @@ public function rules(): array 'min:' . MysqlLimits::CREDITS_MIN, 'gte:price', ], - 'memory' => 'required|numeric|max:1000000|min:5', - 'cpu' => 'required|numeric|max:1000000|min:0', - 'swap' => 'required|numeric|max:1000000|min:0', - 'disk' => 'required|numeric|max:1000000|min:5', - 'io' => 'required|numeric|max:1000000|min:0', - 'serverlimit' => 'required|numeric|max:1000000|min:0', - 'databases' => 'required|numeric|max:1000000|min:0', - 'backups' => 'required|numeric|max:1000000|min:0', - 'allocations' => 'required|numeric|max:1000000|min:0', + 'memory' => 'required|integer|max:1000000|min:5', + 'cpu' => 'required|integer|max:1000000|min:0', + 'swap' => 'required|integer|max:1000000|min:0', + 'disk' => 'required|integer|max:1000000|min:5', + 'io' => 'required|integer|max:1000000|min:0', + 'serverlimit' => 'required|integer|max:1000000|min:0', + 'databases' => 'required|integer|max:1000000|min:0', + 'backups' => 'required|integer|max:1000000|min:0', + 'allocations' => 'required|integer|max:1000000|min:0', 'nodes' => 'sometimes|array', 'nodes.*' => 'integer|exists:nodes,id', 'eggs' => 'sometimes|array', diff --git a/app/Http/Requests/Api/Products/UpdateProductRequest.php b/app/Http/Requests/Api/Products/UpdateProductRequest.php index f261ea10d..eb74f72cc 100644 --- a/app/Http/Requests/Api/Products/UpdateProductRequest.php +++ b/app/Http/Requests/Api/Products/UpdateProductRequest.php @@ -37,15 +37,15 @@ public function rules(): array 'min:' . MysqlLimits::CREDITS_MIN, 'gte:price', ], - 'memory' => 'sometimes|numeric|max:1000000|min:5', - 'cpu' => 'sometimes|numeric|max:1000000|min:0', - 'swap' => 'sometimes|numeric|max:1000000|min:0', - 'disk' => 'sometimes|numeric|max:1000000|min:5', - 'io' => 'sometimes|numeric|max:1000000|min:0', - 'serverlimit' => 'sometimes|numeric|max:1000000|min:0', - 'databases' => 'sometimes|numeric|max:1000000|min:0', - 'backups' => 'sometimes|numeric|max:1000000|min:0', - 'allocations' => 'sometimes|numeric|max:1000000|min:0', + 'memory' => 'sometimes|integer|max:1000000|min:5', + 'cpu' => 'sometimes|integer|max:1000000|min:0', + 'swap' => 'sometimes|integer|max:1000000|min:0', + 'disk' => 'sometimes|integer|max:1000000|min:5', + 'io' => 'sometimes|integer|max:1000000|min:0', + 'serverlimit' => 'sometimes|integer|max:1000000|min:0', + 'databases' => 'sometimes|integer|max:1000000|min:0', + 'backups' => 'sometimes|integer|max:1000000|min:0', + 'allocations' => 'sometimes|integer|max:1000000|min:0', 'nodes' => 'sometimes|array', 'nodes.*' => 'integer|exists:nodes,id', 'eggs' => 'sometimes|array', diff --git a/app/Http/Requests/Api/Servers/UpdateServerRequest.php b/app/Http/Requests/Api/Servers/UpdateServerRequest.php index 135adcabd..52734cc0f 100644 --- a/app/Http/Requests/Api/Servers/UpdateServerRequest.php +++ b/app/Http/Requests/Api/Servers/UpdateServerRequest.php @@ -25,8 +25,8 @@ public function rules(): array { return [ 'name' => 'sometimes|string|max:255', - 'description' => 'nullable|string|max:255', - 'user_id' => 'required|integer|exists:users,id', + 'description' => 'sometimes|nullable|string|max:255', + 'user_id' => 'sometimes|integer|exists:users,id', 'billing_priority' => ['nullable', Rule::enum(BillingPriority::class)], ]; } diff --git a/app/Http/Requests/Api/Users/UpdateUserRequest.php b/app/Http/Requests/Api/Users/UpdateUserRequest.php index 8b7a03f7a..7321ab62b 100644 --- a/app/Http/Requests/Api/Users/UpdateUserRequest.php +++ b/app/Http/Requests/Api/Users/UpdateUserRequest.php @@ -23,8 +23,8 @@ public function authorize(): bool public function rules(): array { return [ - 'name' => 'required|string|min:4|max:30', - 'email' => 'required|string|email', + 'name' => 'sometimes|string|min:4|max:30', + 'email' => 'sometimes|string|email', 'password' => 'sometimes|string|min:8|max:191', 'credits' => ['sometimes', 'numeric', 'min:' . MysqlLimits::CREDITS_MIN, diff --git a/app/Http/Requests/Api/Vouchers/CreateVoucherRequest.php b/app/Http/Requests/Api/Vouchers/CreateVoucherRequest.php index bf963f75a..ffceed45e 100644 --- a/app/Http/Requests/Api/Vouchers/CreateVoucherRequest.php +++ b/app/Http/Requests/Api/Vouchers/CreateVoucherRequest.php @@ -25,7 +25,7 @@ public function rules(): array return [ 'memo' => 'nullable|string|max:191', 'code' => 'required|string|alpha_dash|max:36|min:4|unique:vouchers', - 'uses' => 'required|numeric|max:2147483647|min:1', + 'uses' => 'required|integer|max:2147483647|min:1', 'credits' => ['required', 'numeric', 'min:' . MysqlLimits::CREDITS_MIN, 'max:' . MysqlLimits::CREDITS_MAX, diff --git a/app/Http/Requests/Api/Vouchers/UpdateVoucherRequest.php b/app/Http/Requests/Api/Vouchers/UpdateVoucherRequest.php index f00bd7aa9..dbe055efb 100644 --- a/app/Http/Requests/Api/Vouchers/UpdateVoucherRequest.php +++ b/app/Http/Requests/Api/Vouchers/UpdateVoucherRequest.php @@ -25,7 +25,7 @@ public function rules(): array { return [ 'memo' => 'sometimes|nullable|string|max:191', - 'uses' => 'required|numeric|max:2147483647|min:1', + 'uses' => 'required|integer|max:2147483647|min:1', 'code' => ['required', 'string', 'alpha_dash', 'min:4', 'max:36', Rule::unique('vouchers')->ignore($this->route('voucher')?->id), ], diff --git a/app/Http/Resources/DiscordUserResource.php b/app/Http/Resources/DiscordUserResource.php index 3c5d7193f..2021ddad7 100644 --- a/app/Http/Resources/DiscordUserResource.php +++ b/app/Http/Resources/DiscordUserResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Models\ApplicationApi; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -14,21 +15,30 @@ class DiscordUserResource extends JsonResource */ public function toArray(Request $request): array { - return [ + /** @var ApplicationApi|null $apiToken */ + $apiToken = $request->attributes->get('apiToken'); + $canViewSensitiveFields = ! $apiToken || $apiToken->hasAbility(ApplicationApi::ABILITY_USERS_SENSITIVE); + + $data = [ 'id' => $this->id, 'username' => $this->username, - 'discriminator' => $this->discriminator, 'avatar' => $this->avatar, - 'email' => $this->email, 'verified' => $this->verified, - 'public_flags' => $this->public_flags, - 'flags' => $this->flags, - 'locale' => $this->locale, - 'premium_type' => $this->premium_type, - 'mfa_enabled' => $this->mfa_enabled, 'user_id' => $this->user_id, 'created_at' => $this->created_at->toDateTimeString(), 'updated_at' => $this->updated_at->toDateTimeString(), ]; + + if ($canViewSensitiveFields) { + $data['discriminator'] = $this->discriminator; + $data['email'] = $this->email; + $data['public_flags'] = $this->public_flags; + $data['flags'] = $this->flags; + $data['locale'] = $this->locale; + $data['premium_type'] = $this->premium_type; + $data['mfa_enabled'] = $this->mfa_enabled; + } + + return $data; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index fddbfcba8..904c7a64b 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources; use App\Helpers\CurrencyHelper; +use App\Models\ApplicationApi; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -23,21 +24,18 @@ public function __construct(mixed $resource) */ public function toArray(Request $request): array { - return [ + /** @var ApplicationApi|null $apiToken */ + $apiToken = $request->attributes->get('apiToken'); + $canViewSensitiveFields = ! $apiToken || $apiToken->hasAbility(ApplicationApi::ABILITY_USERS_SENSITIVE); + + $data = [ 'id' => $this->id, 'name' => $this->name, - 'email' => $this->email, 'credits' => $this->currencyHelper->convertForDisplay($this->credits), 'server_limit' => $this->server_limit, - 'pterodactyl_id' => $this->pterodactyl_id, 'avatar' => $this->avatar, - 'ip' => $this->ip, 'suspended' => $this->suspended, - 'referral_code' => $this->referral_code, 'email_verified_reward' => $this->email_verified_reward, - 'discord_verified_at' => $this->discord_verified_at, - 'last_seen' => $this->last_seen, - 'email_verified_at' => $this->email_verified_at, 'created_at' => $this->created_at->toDateTimeString(), 'updated_at' => $this->updated_at->toDateTimeString(), 'servers_count' => $this->whenCounted('servers'), @@ -55,9 +53,24 @@ public function toArray(Request $request): array 'roles_count' => $this->whenCounted('roles'), 'roles_exists' => $this->whenExistsLoaded('roles'), 'roles' => RoleResource::collection($this->whenLoaded('roles')), - 'discord_user' => DiscordUserResource::make($this->whenLoaded('discordUser')), - 'discord_user_exists' => $this->whenExistsLoaded('discordUser'), - 'discord_user_count' => $this->whenCounted('discordUser'), ]; + + if ($canViewSensitiveFields) { + $data['email'] = $this->email; + $data['pterodactyl_id'] = $this->pterodactyl_id; + $data['ip'] = $this->ip; + $data['referral_code'] = $this->referral_code; + $data['discord_verified_at'] = $this->discord_verified_at; + $data['last_seen'] = $this->last_seen; + $data['email_verified_at'] = $this->email_verified_at; + if ($this->relationLoaded('discordUser')) { + $data['discord_user'] = DiscordUserResource::make($this->discordUser); + } + + $data['discord_user_exists'] = $this->whenExistsLoaded('discordUser'); + $data['discord_user_count'] = $this->whenCounted('discordUser'); + } + + return $data; } } diff --git a/app/Http/Resources/VoucherResource.php b/app/Http/Resources/VoucherResource.php index 9acd6f2cc..1a85f26d0 100644 --- a/app/Http/Resources/VoucherResource.php +++ b/app/Http/Resources/VoucherResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources; use App\Helpers\CurrencyHelper; +use App\Models\ApplicationApi; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Request; @@ -23,7 +24,11 @@ public function __construct(mixed $resource) */ public function toArray(Request $request): array { - return [ + /** @var ApplicationApi|null $apiToken */ + $apiToken = $request->attributes->get('apiToken'); + $canViewSensitiveFields = ! $apiToken || $apiToken->hasAbility(ApplicationApi::ABILITY_USERS_SENSITIVE); + + $data = [ 'id' => $this->id, 'code' => $this->code, 'memo' => $this->memo, @@ -32,9 +37,16 @@ public function toArray(Request $request): array 'expires_at' => $this->expires_at ? $this->expires_at->toDateTimeString() : null, 'created_at' => $this->created_at->toDateTimeString(), 'updated_at' => $this->updated_at->toDateTimeString(), - 'users_count' => $this->whenCounted('users'), - 'users_exists' => $this->whenExistsLoaded('users'), - 'users' => UserResource::newCollection($this->whenLoaded('users')), ]; + + if ($canViewSensitiveFields) { + $data['users_count'] = $this->whenCounted('users'); + $data['users_exists'] = $this->whenExistsLoaded('users'); + if ($this->relationLoaded('users')) { + $data['users'] = UserResource::collection($this->users); + } + } + + return $data; } } diff --git a/app/Listeners/AssociateDiscordRoles.php b/app/Listeners/AssociateDiscordRoles.php index 1fbf513c3..dc4ce7f3b 100644 --- a/app/Listeners/AssociateDiscordRoles.php +++ b/app/Listeners/AssociateDiscordRoles.php @@ -26,7 +26,7 @@ public function __construct() public function handle(ServerCreatedEvent $event): void { try { - if ($this->discordSettings->role_for_active_clients && $event->user->discordUser && $event->user->servers->count() > 0) { + if ($this->discordSettings->role_for_active_clients && $event->user->discordUser && $event->user->activeServers()->exists()) { $event->user->discordUser->addOrRemoveRole('add', $this->discordSettings->role_id_for_active_clients); } } catch (Exception $e) { diff --git a/app/Listeners/CouponUsed.php b/app/Listeners/CouponUsed.php index 02c7ed0a2..cd36fc276 100644 --- a/app/Listeners/CouponUsed.php +++ b/app/Listeners/CouponUsed.php @@ -34,8 +34,8 @@ public function handle(CouponUsedEvent $event) $this->incrementUses($event); if ($this->delete_coupon_on_expires) { - if (!is_null($event->coupon->expired_at)) { - if ($event->coupon->expires_at <= Carbon::now()->timestamp) { + if (!is_null($event->coupon->expires_at)) { + if ($event->coupon->expires_at->isPast()) { $event->coupon->delete(); } } diff --git a/app/Listeners/DisassociateDiscordRoles.php b/app/Listeners/DisassociateDiscordRoles.php index 56298673e..0e3a2b282 100644 --- a/app/Listeners/DisassociateDiscordRoles.php +++ b/app/Listeners/DisassociateDiscordRoles.php @@ -27,7 +27,11 @@ public function __construct() public function handle(ServerDeletedEvent $event): void { try { - if ($this->discordSettings->role_for_active_clients && $event->server->user->discordUser && $event->server->user->servers->count() <= 1) { + if ( + $this->discordSettings->role_for_active_clients + && $event->server->user->discordUser + && ! $event->server->user->activeServers()->where('servers.id', '!=', $event->server->id)->exists() + ) { $event->server->user->discordUser->addOrRemoveRole('remove', $this->discordSettings->role_id_for_active_clients); } } catch (Exception $e) { diff --git a/app/Listeners/SendWelcomeMessage.php b/app/Listeners/SendWelcomeMessage.php new file mode 100644 index 000000000..e4884d8d4 --- /dev/null +++ b/app/Listeners/SendWelcomeMessage.php @@ -0,0 +1,14 @@ +user->notify(new WelcomeMessage($event->user)); + } +} diff --git a/app/Listeners/UserPayment.php b/app/Listeners/UserPayment.php index 88621251b..2ea1a9ea3 100644 --- a/app/Listeners/UserPayment.php +++ b/app/Listeners/UserPayment.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Support\Facades\Log; +use Spatie\Permission\Models\Role; class UserPayment implements ShouldQueue { @@ -95,8 +96,11 @@ public function handle(PaymentEvent $event) } } //update role give Referral-reward - if ($user->hasRole(4)) { - $user->syncRoles(3); + if ($user->hasRole('User')) { + $clientRole = Role::query()->where('name', 'Client')->first(); + if ($clientRole) { + $user->syncRoles($clientRole); + } //give referral commission only on first purchase if (($this->referral_mode === "commission" || $this->referral_mode === "both") && $shopProduct->type == "Credits" && !$this->referral_always_give_commission) { diff --git a/app/Listeners/Verified.php b/app/Listeners/Verified.php index b1a625bf8..ab161bb49 100644 --- a/app/Listeners/Verified.php +++ b/app/Listeners/Verified.php @@ -3,6 +3,8 @@ namespace App\Listeners; use App\Settings\UserSettings; +use App\Models\User; +use Illuminate\Support\Facades\DB; class Verified { @@ -28,10 +30,16 @@ public function __construct(UserSettings $user_settings) */ public function handle($event) { - if (!$event->user->email_verified_reward) { - $event->user->increment('server_limit', $this->server_limit_increment_after_verify_email); - $event->user->increment('credits', $this->credits_reward_after_verify_email); - $event->user->update(['email_verified_reward' => true]); - } + DB::transaction(function () use ($event): void { + $user = User::query()->whereKey($event->user->id)->lockForUpdate()->first(); + + if (! $user || $user->email_verified_reward) { + return; + } + + $user->increment('server_limit', $this->server_limit_increment_after_verify_email); + $user->increment('credits', $this->credits_reward_after_verify_email); + $user->update(['email_verified_reward' => true]); + }); } } diff --git a/app/Models/ApplicationApi.php b/app/Models/ApplicationApi.php index 62a03478e..5c12aee9f 100644 --- a/app/Models/ApplicationApi.php +++ b/app/Models/ApplicationApi.php @@ -2,37 +2,233 @@ namespace App\Models; +use Carbon\CarbonInterface; use Hidehalo\Nanoid\Client; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class ApplicationApi extends Model { use HasFactory; - protected $fillable = ['token', 'memo', 'last_used']; + public const ABILITY_USERS_READ = 'users.read'; + public const ABILITY_USERS_WRITE = 'users.write'; + public const ABILITY_USERS_SENSITIVE = 'users.sensitive'; + public const ABILITY_SERVERS_READ = 'servers.read'; + public const ABILITY_SERVERS_WRITE = 'servers.write'; + public const ABILITY_VOUCHERS_READ = 'vouchers.read'; + public const ABILITY_VOUCHERS_WRITE = 'vouchers.write'; + public const ABILITY_ROLES_READ = 'roles.read'; + public const ABILITY_ROLES_WRITE = 'roles.write'; + public const ABILITY_PRODUCTS_READ = 'products.read'; + public const ABILITY_PRODUCTS_WRITE = 'products.write'; + public const ABILITY_NOTIFICATIONS_READ = 'notifications.read'; + public const ABILITY_NOTIFICATIONS_WRITE = 'notifications.write'; - protected $primaryKey = 'token'; + protected $table = 'application_api_tokens'; - public $incrementing = false; + protected $fillable = [ + 'id', + 'owner_user_id', + 'memo', + 'token_hash', + 'token_hint', + 'abilities', + 'expires_at', + 'revoked_at', + 'last_used', + ]; + + protected $hidden = [ + 'token_hash', + ]; protected $casts = [ + 'abilities' => 'array', 'last_used' => 'datetime', + 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', ]; - public static function boot() - { - parent::boot(); + protected $primaryKey = 'id'; - static::creating(function (ApplicationApi $applicationApi) { - $client = new Client(); + public $incrementing = false; - $applicationApi->{$applicationApi->getKeyName()} = $client->generateId(48); + protected static function booted(): void + { + static::creating(function (ApplicationApi $applicationApi) { + if (! $applicationApi->getKey()) { + $applicationApi->{$applicationApi->getKeyName()} = self::generatePublicId(); + } }); } - public function updateLastUsed() + public static function abilityOptions(): array + { + return [ + 'Users' => [ + self::ABILITY_USERS_READ => 'Read users', + self::ABILITY_USERS_WRITE => 'Write users', + self::ABILITY_USERS_SENSITIVE => 'Read sensitive user fields', + ], + 'Servers' => [ + self::ABILITY_SERVERS_READ => 'Read servers', + self::ABILITY_SERVERS_WRITE => 'Write servers', + ], + 'Vouchers' => [ + self::ABILITY_VOUCHERS_READ => 'Read vouchers', + self::ABILITY_VOUCHERS_WRITE => 'Write vouchers', + ], + 'Roles' => [ + self::ABILITY_ROLES_READ => 'Read roles', + self::ABILITY_ROLES_WRITE => 'Write roles', + ], + 'Products' => [ + self::ABILITY_PRODUCTS_READ => 'Read products', + self::ABILITY_PRODUCTS_WRITE => 'Write products', + ], + 'Notifications' => [ + self::ABILITY_NOTIFICATIONS_READ => 'Read notifications', + self::ABILITY_NOTIFICATIONS_WRITE => 'Write notifications', + ], + ]; + } + + public static function availableAbilities(): array + { + return collect(self::abilityOptions()) + ->flatMap(fn (array $abilities) => array_keys($abilities)) + ->values() + ->all(); + } + + public static function issue(?int $ownerUserId, ?string $memo, array $abilities, ?CarbonInterface $expiresAt = null): array + { + $plainSecret = self::generateSecret(); + $token = self::create([ + 'owner_user_id' => $ownerUserId, + 'memo' => $memo, + 'token_hash' => hash('sha256', $plainSecret), + 'token_hint' => substr($plainSecret, -4), + 'abilities' => array_values(array_unique($abilities)), + 'expires_at' => $expiresAt, + 'revoked_at' => null, + 'last_used' => null, + ]); + + return [$token, self::formatPlainTextToken($token->id, $plainSecret)]; + } + + public function rotate(?CarbonInterface $expiresAt = null): string + { + $plainSecret = self::generateSecret(); + + $this->forceFill([ + 'token_hash' => hash('sha256', $plainSecret), + 'token_hint' => substr($plainSecret, -4), + 'expires_at' => $expiresAt, + 'revoked_at' => null, + 'last_used' => null, + ])->save(); + + return self::formatPlainTextToken($this->id, $plainSecret); + } + + public static function findToken(string $plainTextToken): ?self + { + if (! preg_match('/^cpgg_([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/', $plainTextToken, $matches)) { + return null; + } + + [, $id, $plainSecret] = $matches; + + /** @var self|null $token */ + $token = self::query()->find($id); + + if (! $token) { + return null; + } + + return hash_equals($token->token_hash, hash('sha256', $plainSecret)) ? $token : null; + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function hasAbility(string $ability): bool + { + $abilities = $this->abilities ?? []; + + return in_array('*', $abilities, true) || in_array($ability, $abilities, true); + } + + public function hasAnyAbility(array $abilities): bool + { + foreach ($abilities as $ability) { + if ($this->hasAbility($ability)) { + return true; + } + } + + return false; + } + + public function isActive(): bool + { + if ($this->revoked_at !== null) { + return false; + } + + if ($this->expires_at !== null && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + public function updateLastUsed(): void + { + $this->forceFill(['last_used' => now()])->save(); + } + + public function getRouteKeyName(): string + { + return 'id'; + } + + public function getDisplayTokenIdentifierAttribute(): string + { + return sprintf('cpgg_%s...%s', $this->id, $this->token_hint); + } + + public function getStatusLabelAttribute(): string + { + if ($this->revoked_at !== null) { + return 'Revoked'; + } + + if ($this->expires_at !== null && $this->expires_at->isPast()) { + return 'Expired'; + } + + return 'Active'; + } + + private static function generatePublicId(): string + { + return (new Client())->generateId(12, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + + private static function generateSecret(): string + { + return (new Client())->generateId(48, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); + } + + private static function formatPlainTextToken(string $id, string $secret): string { - $this->update(['last_used' => now()]); + return "cpgg_{$id}.{$secret}"; } } diff --git a/app/Models/Coupon.php b/app/Models/Coupon.php index 87543df22..01853ab5e 100644 --- a/app/Models/Coupon.php +++ b/app/Models/Coupon.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Traits\CausesActivity; use Spatie\Activitylog\Traits\LogsActivity; use Carbon\Carbon; @@ -17,6 +18,23 @@ class Coupon extends Model { use HasFactory, LogsActivity, CausesActivity; + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + + foreach (['attributes', 'old'] as $section) { + if (!isset($properties[$section]) || !is_array($properties[$section])) { + continue; + } + + if (array_key_exists('code', $properties[$section])) { + $properties[$section]['code'] = '[redacted]'; + } + } + + $activity->properties = $properties; + } + public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() @@ -122,9 +140,17 @@ public static function generateRandomCoupon(int $amount = 10): array { $coupons = []; - for ($i = 0; $i < $amount; $i++) { + while (count($coupons) < $amount) { $random_coupon = strtoupper(bin2hex(random_bytes(3))); + if (in_array($random_coupon, $coupons, true)) { + continue; + } + + if (self::query()->where('code', $random_coupon)->exists()) { + continue; + } + $coupons[] = $random_coupon; } diff --git a/app/Models/DiscordUser.php b/app/Models/DiscordUser.php index d1bc21398..cb25b9384 100644 --- a/app/Models/DiscordUser.php +++ b/app/Models/DiscordUser.php @@ -67,17 +67,21 @@ public function addOrRemoveRole(string $action, string $role_id) 'Content-Type' => 'application/json', 'X-Audit-Log-Reason' => 'Role added by panel' ] - )->put($url), + )->timeout(30)->connectTimeout(10)->put($url), 'remove' => Http::withHeaders( [ 'Authorization' => 'Bot ' . $discordSettings->bot_token, 'Content-Type' => 'application/json', 'X-Audit-Log-Reason' => 'Role removed by panel' ] - )->delete($url), + )->timeout(30)->connectTimeout(10)->delete($url), default => null }; + if ($response === null) { + throw new Exception('Invalid Discord role action supplied.'); + } + if ($response->failed()) { throw new Exception( "Discord API error: {$response->status()} - " . @@ -89,7 +93,7 @@ public function addOrRemoveRole(string $action, string $role_id) activity() ->performedOn($this->user) ->causedBy($this->user) - ->log('was added to role ' . $this->role_id_on_purchase . " on Discord"); + ->log('was ' . $action . 'ed role ' . $role_id . ' on Discord'); return true; diff --git a/app/Models/PartnerDiscount.php b/app/Models/PartnerDiscount.php index 46c0269a7..d3713b761 100644 --- a/app/Models/PartnerDiscount.php +++ b/app/Models/PartnerDiscount.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -37,10 +38,15 @@ public static function getCommission($user_id, $percentage) { if ($partnerDiscount = PartnerDiscount::where('user_id', $user_id)->first()) { if ($partnerDiscount->referral_system_commission >= 0) { - return $partnerDiscount->referral_system_commission >= 0; + return $partnerDiscount->referral_system_commission; } } return $percentage; } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 7fdccad65..c3706e6c3 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -29,7 +29,25 @@ public function getActivitylogOptions(): LogOptions } public $incrementing = false; - protected $guarded = ['id']; + protected $fillable = [ + 'name', + 'description', + 'price', + 'memory', + 'cpu', + 'swap', + 'disk', + 'io', + 'databases', + 'backups', + 'allocations', + 'serverlimit', + 'minimum_credits', + 'disabled', + 'oom_killer', + 'billing_period', + 'default_billing_priority', + ]; /** * @var string[] @@ -80,7 +98,7 @@ protected function price(): Attribute protected function minimumCredits(): Attribute { return Attribute::make( - set: fn ($value) => $value ? Currency::prepareForDatabase($value) : null + set: fn ($value) => $value === null || $value === '' ? null : Currency::prepareForDatabase($value) ); } diff --git a/app/Models/Pterodactyl/Egg.php b/app/Models/Pterodactyl/Egg.php index 24e4a8e00..e4d6307ac 100644 --- a/app/Models/Pterodactyl/Egg.php +++ b/app/Models/Pterodactyl/Egg.php @@ -50,25 +50,36 @@ public static function syncEggs() foreach ($eggs as $egg) { $array = []; $environment = []; + $eggAttributes = data_get($egg, 'attributes'); - $array['id'] = $egg['attributes']['id']; - $array['nest_id'] = $egg['attributes']['nest']; - $array['name'] = $egg['attributes']['name']; - $array['description'] = $egg['attributes']['description']; - $array['docker_image'] = $egg['attributes']['docker_image']; - $array['startup'] = $egg['attributes']['startup']; + if (! is_array($eggAttributes)) { + continue; + } + + $array['id'] = $eggAttributes['id']; + $array['nest_id'] = $eggAttributes['nest']; + $array['name'] = $eggAttributes['name']; + $array['description'] = $eggAttributes['description']; + $array['docker_image'] = $eggAttributes['docker_image']; + $array['startup'] = $eggAttributes['startup']; $array['updated_at'] = now(); //get environment variables - foreach ($egg['attributes']['relationships']['variables']['data'] as $variable) { + foreach ((array) data_get($eggAttributes, 'relationships.variables.data', []) as $variable) { + $variableAttributes = data_get($variable, 'attributes'); + + if (! is_array($variableAttributes)) { + continue; + } + $environment[] = [ - 'name' => $variable['attributes']['name'], - 'description' => $variable['attributes']['description'], - 'default_value' => $variable['attributes']['default_value'], - 'env_variable' => $variable['attributes']['env_variable'], - 'user_viewable' => $variable['attributes']['user_viewable'], - 'user_editable' => $variable['attributes']['user_editable'], - 'rules' => $variable['attributes']['rules'], + 'name' => $variableAttributes['name'], + 'description' => $variableAttributes['description'], + 'default_value' => $variableAttributes['default_value'], + 'env_variable' => $variableAttributes['env_variable'], + 'user_viewable' => $variableAttributes['user_viewable'], + 'user_editable' => $variableAttributes['user_editable'], + 'rules' => $variableAttributes['rules'], ]; } diff --git a/app/Models/Pterodactyl/Location.php b/app/Models/Pterodactyl/Location.php index ddb1f7155..56b5f2a11 100644 --- a/app/Models/Pterodactyl/Location.php +++ b/app/Models/Pterodactyl/Location.php @@ -6,6 +6,7 @@ use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class Location extends Model { @@ -20,8 +21,12 @@ public static function boot() parent::boot(); // TODO: Change the autogenerated stub static::deleting(function (Location $location) { - $location->nodes()->each(function (Node $node) { - $node->delete(); + DB::transaction(function () use ($location): void { + $location->nodes()->chunkById(100, function ($nodes): void { + foreach ($nodes as $node) { + $node->delete(); + } + }); }); }); } @@ -45,20 +50,21 @@ public static function syncLocations() ]; }, $locations); - //update or create - foreach ($locations as $location) { - self::query()->updateOrCreate( - [ - 'id' => $location['id'], - ], - [ - 'name' => $location['name'], - 'description' => $location['description'], - ] - ); - } + DB::transaction(function () use ($locations): void { + foreach ($locations as $location) { + self::query()->updateOrCreate( + [ + 'id' => $location['id'], + ], + [ + 'name' => $location['name'], + 'description' => $location['description'], + ] + ); + } - self::removeDeletedLocation($locations); + self::removeDeletedLocation($locations); + }); } /** diff --git a/app/Models/Pterodactyl/Nest.php b/app/Models/Pterodactyl/Nest.php index 699c52db9..f276f14fc 100644 --- a/app/Models/Pterodactyl/Nest.php +++ b/app/Models/Pterodactyl/Nest.php @@ -5,6 +5,7 @@ use App\Classes\PterodactylClient; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class Nest extends Model { @@ -24,8 +25,12 @@ public static function boot() parent::boot(); // TODO: Change the autogenerated stub static::deleting(function (Nest $nest) { - $nest->eggs()->each(function (Egg $egg) { - $egg->delete(); + DB::transaction(function () use ($nest): void { + $nest->eggs()->chunkById(100, function ($eggs): void { + foreach ($eggs as $egg) { + $egg->delete(); + } + }); }); }); } @@ -44,17 +49,19 @@ public static function syncNests() ]; }, $nests); - foreach ($nests as $nest) { - self::query()->updateOrCreate([ - 'id' => $nest['id'], - ], [ - 'name' => $nest['name'], - 'description' => $nest['description'], - 'disabled' => false, - ]); - } + DB::transaction(function () use ($nests): void { + foreach ($nests as $nest) { + self::query()->updateOrCreate([ + 'id' => $nest['id'], + ], [ + 'name' => $nest['name'], + 'description' => $nest['description'], + 'disabled' => false, + ]); + } - self::removeDeletedNests($nests); + self::removeDeletedNests($nests); + }); } /** diff --git a/app/Models/Pterodactyl/Node.php b/app/Models/Pterodactyl/Node.php index 36176f54e..ea324edae 100644 --- a/app/Models/Pterodactyl/Node.php +++ b/app/Models/Pterodactyl/Node.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use App\Models\Product; +use Illuminate\Support\Facades\DB; class Node extends Model { @@ -23,7 +24,9 @@ public static function boot() parent::boot(); // TODO: Change the autogenerated stub static::deleting(function (Node $node) { - $node->products()->detach(); + DB::transaction(function () use ($node): void { + $node->products()->detach(); + }); }); } @@ -46,21 +49,23 @@ public static function syncNodes() ]; }, $nodes); - //update or create - foreach ($nodes as $node) { - self::query()->updateOrCreate( - [ - 'id' => $node['id'], - ], - [ - 'name' => $node['name'], - 'description' => $node['description'], - 'location_id' => $node['location_id'], - 'disabled' => false, - ]); - } + DB::transaction(function () use ($nodes): void { + foreach ($nodes as $node) { + self::query()->updateOrCreate( + [ + 'id' => $node['id'], + ], + [ + 'name' => $node['name'], + 'description' => $node['description'], + 'location_id' => $node['location_id'], + 'disabled' => false, + ] + ); + } - self::removeDeletedNodes($nodes); + self::removeDeletedNodes($nodes); + }); } /** diff --git a/app/Models/Server.php b/app/Models/Server.php index 63f1d6142..9241789e7 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -24,7 +24,7 @@ class Server extends Model use HasFactory; use LogsActivity; - private PterodactylClient $pterodactyl; + private ?PterodactylClient $pterodactyl = null; public function getActivitylogOptions(): LogOptions { @@ -76,12 +76,9 @@ public function getActivitylogOptions(): LogOptions 'billing_priority' => BillingPriority::class ]; - public function __construct() + public function __construct(array $attributes = []) { - parent::__construct(); - - $ptero_settings = new PterodactylSettings(); - $this->pterodactyl = new PterodactylClient($ptero_settings); + parent::__construct($attributes); } public static function boot() @@ -95,11 +92,11 @@ public static function boot() }); static::deleting(function (Server $server) { - $response = $server->pterodactyl->application->delete("/application/servers/{$server->pterodactyl_id}"); + $response = $server->pterodactyl()->application->delete("/application/servers/{$server->pterodactyl_id}"); if ($response->failed() && !is_null($server->pterodactyl_id)) { - //only return error when it's not a 404 error - if ($response['errors'][0]['status'] != '404') { - throw new Exception($response['errors'][0]['code']); + $status = (string) data_get($response->json(), 'errors.0.status', ''); + if ($status !== '404') { + throw new Exception((string) data_get($response->json(), 'errors.0.code', $response->body())); } } }); @@ -118,7 +115,7 @@ public function isSuspended() */ public function getPterodactylServer() { - return $this->pterodactyl->application->get("/application/servers/{$this->pterodactyl_id}"); + return $this->pterodactyl()->application->get("/application/servers/{$this->pterodactyl_id}"); } /** @@ -126,14 +123,16 @@ public function getPterodactylServer() */ public function suspend() { - $response = $this->pterodactyl->suspendServer($this); + $response = $this->pterodactyl()->suspendServer($this); - if ($response->successful()) { - $this->update([ - 'suspended' => now(), - ]); + if (! $response->successful()) { + throw new Exception((string) data_get($response->json(), 'errors.0.detail', 'Failed to suspend server.')); } + $this->update([ + 'suspended' => now(), + ]); + return $this; } @@ -142,16 +141,17 @@ public function suspend() */ public function unSuspend() { - $response = $this->pterodactyl->unSuspendServer($this); - - if ($response->successful()) { - $this->update([ - 'suspended' => null, - 'last_billed' => Carbon::now()->toDateTimeString(), - 'suspension_warning_sent_at' => null, - ]); + $response = $this->pterodactyl()->unSuspendServer($this); + + if (! $response->successful()) { + throw new Exception((string) data_get($response->json(), 'errors.0.detail', 'Failed to unsuspend server.')); } + $this->update([ + 'suspended' => null, + 'last_billed' => Carbon::now()->toDateTimeString(), + 'suspension_warning_sent_at' => null, + ]); return $this; } @@ -186,4 +186,13 @@ public function scopeByBillingPriority($query) ))') ->orderBy('created_at', 'asc'); } + + private function pterodactyl(): PterodactylClient + { + if ($this->pterodactyl === null) { + $this->pterodactyl = new PterodactylClient(app(PterodactylSettings::class)); + } + + return $this->pterodactyl; + } } diff --git a/app/Models/User.php b/app/Models/User.php index 301a661f6..62f747216 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,7 +3,6 @@ namespace App\Models; use App\Notifications\Auth\QueuedVerifyEmail; -use App\Notifications\WelcomeMessage; use App\Classes\PterodactylClient; use App\Facades\Currency; use App\Settings\PterodactylSettings; @@ -19,6 +18,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Traits\CausesActivity; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Permission\Traits\HasRoles; @@ -30,12 +30,12 @@ class User extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable, LogsActivity, CausesActivity, HasRoles; - private PterodactylClient $pterodactyl; + private ?PterodactylClient $pterodactyl = null; /** * @var string[] */ - protected static $logAttributes = ['name', 'email']; + protected static $logAttributes = ['name']; /** * @var string[] @@ -95,54 +95,59 @@ class User extends Authenticatable implements MustVerifyEmail 'email_verified_reward' => 'boolean' ]; - public function __construct() + public function __construct(array $attributes = []) { - parent::__construct(); - - $ptero_settings = new PterodactylSettings(); - $this->pterodactyl = new PterodactylClient($ptero_settings); + parent::__construct($attributes); } public static function boot() { parent::boot(); - static::created(function (User $user) { - $user->notify(new WelcomeMessage($user)); - }); - static::deleting(function (User $user) { - - - // delete every server the user owns without using chunks - $user->servers()->each(function ($server) { - $server->delete(); + DB::transaction(function () use ($user) { + foreach ($user->servers()->cursor() as $server) { + $server->delete(); + } + + $user->payments()->delete(); + $user->tickets()->delete(); + $user->ticketBlackList()->delete(); + $user->vouchers()->detach(); + $user->discordUser()->delete(); + + $referralRecords = DB::table('user_referrals')->where('registered_user_id', $user->id)->get(); + foreach ($referralRecords as $ref) { + DB::table('user_referrals') + ->where('referral_id', $ref->referral_id) + ->where('registered_user_id', $ref->registered_user_id) + ->update([ + 'deleted_at' => now(), + 'deleted_username' => $user->name, + 'deleted_user_id' => $user->id, + ]); + } + + if ($user->pterodactyl_id) { + $response = $user->pterodactyl()->application->delete("/application/users/{$user->pterodactyl_id}"); + $status = (string) data_get($response->json(), 'errors.0.status', ''); + if ($response->failed() && $status !== '404') { + throw new \RuntimeException( + (string) data_get($response->json(), 'errors.0.detail', $response->body()) + ); + } + } }); + }); + } - $user->payments()->delete(); - - $user->tickets()->delete(); - - $user->ticketBlackList()->delete(); - - $user->vouchers()->detach(); - - $user->discordUser()->delete(); - - $referralRecords = DB::table('user_referrals')->where('registered_user_id', $user->id)->get(); - foreach ($referralRecords as $ref) { - DB::table('user_referrals') - ->where('referral_id', $ref->referral_id) - ->where('registered_user_id', $ref->registered_user_id) - ->update([ - 'deleted_at' => now(), - 'deleted_username' => $user->name, - 'deleted_user_id' => $user->id, - ]); - } + private function pterodactyl(): PterodactylClient + { + if ($this->pterodactyl === null) { + $this->pterodactyl = new PterodactylClient(app(PterodactylSettings::class)); + } - $user->pterodactyl->application->delete("/application/users/{$user->pterodactyl_id}"); - }); + return $this->pterodactyl; } /** @@ -172,6 +177,16 @@ public function servers() return $this->hasMany(Server::class); } + /** + * @return HasMany + */ + public function activeServers() + { + return $this->servers() + ->whereNull('canceled') + ->whereNull('suspended'); + } + /** * @return HasMany */ @@ -220,12 +235,14 @@ public function discordUser() return $this->hasOne(DiscordUser::class); } - public function sendEmailVerificationNotification() + public function sendEmailVerificationNotification(): bool { try { + $rateLimitKey = 'verify-mail:' . $this->id; + // Rate limit the email verification notification to 5 attempt per 30 minutes $executed = RateLimiter::attempt( - key: 'verify-mail' . $this->id, + key: $rateLimitKey, maxAttempts: 5, callback: function () { $this->notify(new QueuedVerifyEmail); @@ -234,11 +251,14 @@ public function sendEmailVerificationNotification() ); if (!$executed) { - return redirect()->back()->with('error', 'Too many requests. Try again in ' . RateLimiter::availableIn('verify-mail:' . $this->id) . ' seconds.'); + return false; } + + return true; }catch (\Exception $exception){ Log::error($exception->getMessage()); - return redirect()->back()->with('error', __("Something went wrong. Please try again later!")); + + return false; } } @@ -265,14 +285,18 @@ public function suspend() public function unSuspend() { - foreach ($this->getServersWithProduct() as $server) { - if ($this->credits >= $server->product->getHourlyPrice()) { + $availableCredits = $this->credits; + + foreach ($this->getSuspendedServersWithProduct() as $server) { + $hourlyPrice = $server->product->getHourlyPrice(); + if ($availableCredits >= $hourlyPrice) { $server->unSuspend(); + $availableCredits -= $hourlyPrice; } } $this->update([ - 'suspended' => false, + 'suspended' => $this->servers()->whereNotNull('suspended')->exists(), ]); return $this; @@ -284,6 +308,10 @@ public function unSuspend() */ public function getAvatar() { + if (! empty($this->avatar)) { + return $this->avatar; + } + return 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($this->email))); } @@ -307,6 +335,15 @@ public function getServersWithProduct() ->get(); } + public function getSuspendedServersWithProduct() + { + return $this->servers() + ->whereNotNull('suspended') + ->whereNull('canceled') + ->with('product') + ->get(); + } + /** * @return array|string|string[] */ @@ -352,9 +389,26 @@ public function referredBy() public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() - ->logOnly(['role', 'name', 'server_limit', 'pterodactyl_id', 'email', 'credits', 'server_limit', 'suspended', 'referral_code']) + ->logOnly(['role', 'name', 'server_limit', 'pterodactyl_id', 'credits', 'server_limit', 'suspended', 'referral_code']) ->logOnlyDirty() ->dontSubmitEmptyLogs() ->dontLogIfAttributesChangedOnly(['credits', 'server_limit', 'updated_at']); } + + public function tapActivity(Activity $activity, string $eventName): void + { + $properties = $activity->properties?->toArray() ?? []; + + foreach (['attributes', 'old'] as $section) { + if (! isset($properties[$section]) || ! is_array($properties[$section])) { + continue; + } + + if (array_key_exists('email', $properties[$section])) { + $properties[$section]['email'] = '[redacted]'; + } + } + + $activity->properties = $properties; + } } diff --git a/app/Models/Voucher.php b/app/Models/Voucher.php index 3bf9a96ff..44bc13f4d 100644 --- a/app/Models/Voucher.php +++ b/app/Models/Voucher.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Support\Facades\DB; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Traits\CausesActivity; @@ -29,6 +30,10 @@ public function tapActivity(Activity $activity, string $eventName): void continue; } + if (array_key_exists('code', $properties[$section])) { + $properties[$section]['code'] = '[redacted]'; + } + if (array_key_exists('credits', $properties[$section]) && is_numeric($properties[$section]['credits'])) { $properties[$section]['credits'] = Currency::convertForDisplay((float) $properties[$section]['credits']); } @@ -144,15 +149,27 @@ public function getStatus() */ public function redeem(User $user) { - try { - $user->increment('credits', $this->credits); - $this->users()->attach($user); - $this->logRedeem($user); - } catch (Exception $exception) { - throw $exception; - } + return DB::transaction(function () use ($user) { + $voucher = self::query()->lockForUpdate()->findOrFail($this->id); - return $this->credits; + if ($voucher->users()->whereKey($user->id)->exists()) { + throw new Exception(__('Voucher has already been redeemed by this user.')); + } + + if ($voucher->users()->count() >= $voucher->uses) { + throw new Exception(__('Voucher usage limit has been reached.')); + } + + if ($voucher->expires_at?->isPast()) { + throw new Exception(__('Voucher has expired.')); + } + + $user->increment('credits', $voucher->credits); + $voucher->users()->attach($user); + $voucher->logRedeem($user); + + return $voucher->credits; + }); } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f22d09930..b2c5c7ff0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use App\Helpers\CallHomeHelper; use App\Models\UsefulLink; +use App\Support\RecaptchaV2; use Exception; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Event; @@ -58,6 +59,14 @@ public function boot() return $ok; }); + Validator::extend( + 'recaptcha', + function ($attribute, $value) { + return RecaptchaV2::verify($value, request()?->ip()); + }, + __('Please verify the reCAPTCHA challenge.') + ); + // Force HTTPS if APP_URL is set to https if (config('app.url') && parse_url(config('app.url'), PHP_URL_SCHEME) === 'https') { URL::forceScheme('https'); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ab52317b1..bb95abc73 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -11,6 +11,7 @@ use App\Listeners\CouponUsed; use App\Listeners\CreateInvoice; use App\Listeners\DisassociateDiscordRoles; +use App\Listeners\SendWelcomeMessage; use App\Listeners\UnsuspendServers; use App\Listeners\UserPayment; use App\Listeners\Verified as ListenerVerified; @@ -30,6 +31,7 @@ class EventServiceProvider extends ServiceProvider protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, + SendWelcomeMessage::class, ], UserUpdateCreditsEvent::class => [ UnsuspendServers::class, diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 4cebf1a54..89daff46d 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -6,17 +6,13 @@ use App\Settings\MailSettings; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use App\Settings\DiscordSettings; use Qirolab\Theme\Theme; -use Exception; +use Throwable; class SettingsServiceProvider extends ServiceProvider { - protected $discordSettings; - protected $generalSettings; - /** * Register any application services. * @@ -30,26 +26,24 @@ public function register() /** * Bootstrap any application services. * - * @param DiscordSettings $discordSettings - * @param GeneralSettings $generalSettings * @return void */ - public function boot(DiscordSettings $discordSettings, GeneralSettings $generalSettings) + public function boot() { - $this->discordSettings = $discordSettings; - $this->generalSettings = $generalSettings; - - if (config('app.key') == null) return; - if (!Schema::hasColumn('settings', 'payload')) return; + if (config('app.key') == null) { + return; + } try { + $discordSettings = $this->app->make(DiscordSettings::class); + $generalSettings = $this->app->make(GeneralSettings::class); + /* * DISCORD */ // Inject the settings into the config - Config::set('services.discord.client_id', $this->discordSettings->client_id ?: ""); - Config::set('services.discord.client_secret', $this->discordSettings->client_secret ?: ""); - Config::set('services.discord.redirect', config('app.url', 'http://localhost') . '/auth/callback'); + Config::set('services.discord.client_id', $discordSettings->client_id ?: ""); + Config::set('services.discord.client_secret', $discordSettings->client_secret ?: ""); // optional Config::set('services.discord.allow_gif_avatars', true); Config::set('services.discord.avatar_default_extension', 'jpg'); @@ -57,17 +51,22 @@ public function boot(DiscordSettings $discordSettings, GeneralSettings $generalS /* * RECAPTCHA */ - Config::set('recaptcha.api_site_key', $this->generalSettings->recaptcha_site_key ?: ""); - Config::set('recaptcha.api_secret_key', $this->generalSettings->recaptcha_secret_key ?: ""); + Config::set('recaptcha.api_site_key', $generalSettings->recaptcha_site_key ?: ""); + Config::set('recaptcha.api_secret_key', $generalSettings->recaptcha_secret_key ?: ""); + + Config::set('recaptchav3.sitekey', $generalSettings->recaptcha_site_key ?: ""); + Config::set('recaptchav3.secret', $generalSettings->recaptcha_secret_key ?: ""); - Config::set('recaptchav3.sitekey', $this->generalSettings->recaptcha_site_key ?: ""); - Config::set('recaptchav3.secret', $this->generalSettings->recaptcha_secret_key ?: ""); + Config::set('turnstile.turnstile_site_key', $generalSettings->recaptcha_site_key ?: ""); + Config::set('turnstile.turnstile_secret_key', $generalSettings->recaptcha_secret_key ?: ""); - Config::set('turnstile.turnstile_site_key', $this->generalSettings->recaptcha_site_key ?: ""); - Config::set('turnstile.turnstile_secret_key', $this->generalSettings->recaptcha_secret_key ?: ""); + } catch (Throwable $e) { + if ($this->shouldSilentlySkipSettingsBootstrap($e)) { + return; + } - } catch (Exception $e) { - Log::error("Couldn't find settings. Probably the installation is not complete. " . $e); + report($e); + return; } try { @@ -75,6 +74,7 @@ public function boot(DiscordSettings $discordSettings, GeneralSettings $generalS if (!file_exists(base_path('themes') . "/" . $generalSettings->theme)) { $generalSettings->theme = "default"; + $generalSettings->save(); } if ($generalSettings->theme && $generalSettings->theme !== config('theme.active')) { @@ -86,8 +86,24 @@ public function boot(DiscordSettings $discordSettings, GeneralSettings $generalS $settings = $this->app->make(MailSettings::class); $settings->setConfig(); - } catch (Exception $e) { - Log::error("Couldnt load Settings. Probably the installation is not completet. " . $e); + } catch (Throwable $e) { + if ($this->shouldSilentlySkipSettingsBootstrap($e)) { + Theme::set("default", "default"); + return; + } + + report($e); + Theme::set("default", "default"); } } + + private function shouldSilentlySkipSettingsBootstrap(Throwable $e): bool + { + $message = strtolower($e->getMessage()); + + return str_contains($message, 'no such table') + || str_contains($message, 'table') + || str_contains($message, 'settings') + || str_contains($message, 'app key'); + } } diff --git a/app/Rules/EggBelongsToProduct.php b/app/Rules/EggBelongsToProduct.php index cf6569930..fdf9fa820 100644 --- a/app/Rules/EggBelongsToProduct.php +++ b/app/Rules/EggBelongsToProduct.php @@ -33,6 +33,12 @@ public function setData(array $data): void */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! array_key_exists('product_id', $this->data)) { + $fail('The selected product is invalid.'); + + return; + } + $exists = DB::table('egg_product') ->where('product_id', $this->data['product_id']) ->where('egg_id', $value) diff --git a/app/Rules/ValidateEggVariables.php b/app/Rules/ValidateEggVariables.php index 923bef61e..787098c07 100644 --- a/app/Rules/ValidateEggVariables.php +++ b/app/Rules/ValidateEggVariables.php @@ -34,6 +34,12 @@ public function setData(array $data): void */ public function validate(string $attribute, mixed $value, Closure $fail): void { + if (! array_key_exists('egg_id', $this->data)) { + $fail('The selected egg is invalid.'); + + return; + } + $egg = DB::table('eggs')->where('id', $this->data['egg_id'])->first(); if (!$egg) { @@ -42,10 +48,21 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $environment = collect(json_decode($egg->environment, true)); + $decodedEnvironment = json_decode($egg->environment, true); + + if (! is_array($decodedEnvironment)) { + $fail('The selected egg has invalid environment metadata.'); + + return; + } + + $environment = collect($decodedEnvironment); $environment = $environment->filter(function ($item) { - return str_contains($item['rules'], 'required') && empty($item['default_value']); + return is_array($item) + && isset($item['rules'], $item['env_variable']) + && str_contains((string) $item['rules'], 'required') + && empty($item['default_value']); }); if (!$environment->isEmpty()) { diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index 402e0a21c..80ef18b83 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -15,7 +15,9 @@ class NotificationService */ public function sendToUsers(Collection $users, array $via, ?array $database = null, ?MailMessage $mail = null): void { - Notification::send($users, new DynamicNotification($via, $database, $mail)); + $users->chunk(100)->each(function (Collection $chunk) use ($via, $database, $mail): void { + Notification::send($chunk, new DynamicNotification($via, $database, $mail)); + }); } /** @@ -45,4 +47,4 @@ public function sendEmailNotification(User $user, string $subject, string $conte $this->sendToUser($user, ['mail'], null, $mail); } -} \ No newline at end of file +} diff --git a/app/Services/ServerCreationService.php b/app/Services/ServerCreationService.php index 24ebb4b7c..bd09b18ae 100644 --- a/app/Services/ServerCreationService.php +++ b/app/Services/ServerCreationService.php @@ -6,30 +6,26 @@ use App\Models\Server; use App\Models\User; use App\Models\Product; +use App\Models\Pterodactyl\Egg; use App\Models\Pterodactyl\Node; use App\Settings\GeneralSettings; use App\Settings\PterodactylSettings; use App\Settings\ServerSettings; use App\Settings\UserSettings; +use Illuminate\Support\Facades\DB; class ServerCreationService { - private PterodactylSettings $pterodactylSettings; - private UserSettings $userSettings; - private GeneralSettings $generalSettings; - private ServerSettings $serverSettings; - private PterodactylClient $pterodactylClient; + private ?UserSettings $userSettings = null; + private ?GeneralSettings $generalSettings = null; + private ?ServerSettings $serverSettings = null; + private ?PterodactylClient $pterodactylClient = null; /** * Create a new class instance. */ public function __construct() { - $this->pterodactylSettings = app(PterodactylSettings::class); - $this->userSettings = app(UserSettings::class); - $this->generalSettings = app(GeneralSettings::class); - $this->serverSettings = app(ServerSettings::class); - $this->pterodactylClient = app(PterodactylClient::class, [$this->pterodactylSettings]); } /** @@ -44,43 +40,55 @@ public function __construct() */ public function handle(User $user, Product $product, mixed $data): Server { - $egg = $product->eggs->firstWhere('id', $data['egg_id']); - try { - $validatedData = $this->validateAndPrepare($user, $product, $data); - - $server = Server::create([ - 'name' => $data['name'], - 'user_id' => $user->id, - 'product_id' => $product->id, - 'node_id' => $validatedData['node_id'], - 'last_billed' => now(), - 'billing_priority' => isset($data['billing_priority']) ? $data['billing_priority'] : $product->default_billing_priority, - ]); - - $response = $this->pterodactylClient->createServer($server, $egg, $validatedData['allocation_id'], isset($data['egg_variables']) ? $data['egg_variables'] : null); - - if ($response->failed()) { - logger()->error('Failed to create server on Pterodactyl', [ - 'server_id' => $server->id, - 'status' => $response->status(), - 'error' => $response->json() + return DB::transaction(function () use ($user, $product, $data) { + $lockedUser = User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $validatedData = $this->validateAndPrepare($lockedUser, $product, $data); + $egg = $product->loadMissing('eggs')->eggs->firstWhere('id', $data['egg_id']); + + if (! $egg instanceof Egg) { + throw new \Exception('Selected egg is not available for this product.', 422); + } + + $server = Server::create([ + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'user_id' => $lockedUser->id, + 'product_id' => $product->id, + 'node_id' => $validatedData['node_id'], + 'last_billed' => now(), + 'billing_priority' => isset($data['billing_priority']) ? $data['billing_priority'] : $product->default_billing_priority, ]); - $server->delete(); + $response = $this->pterodactylClient()->createServer($server, $egg, $validatedData['allocation_id'], $data['egg_variables'] ?? null); + + if ($response->failed()) { + logger()->error('Failed to create server on Pterodactyl', [ + 'server_id' => $server->id, + 'status' => $response->status(), + 'error' => $response->json() + ]); + + throw new \Exception('Failed to create server on Pterodactyl.'); + } + + $serverAttributes = data_get($response->json(), 'attributes'); + + if (! is_array($serverAttributes) || ! isset($serverAttributes['id'], $serverAttributes['identifier'])) { + throw new \Exception('Invalid response received from Pterodactyl.', 500); + } - throw new \Exception('Failed to create server on Pterodactyl: ' . $response->json()['errors'][0]['detail'] ?? 'Unknown error'); - } + $server->update([ + 'pterodactyl_id' => $serverAttributes['id'], + 'identifier' => $serverAttributes['identifier'] + ]); - $serverAttributes = $response->json()['attributes']; - $server->update([ - 'pterodactyl_id' => $serverAttributes['id'], - 'identifier' => $serverAttributes['identifier'] - ]); + $lockedUser->decrement('credits', $product->price); - return $server; + return $server; + }); } catch (\Exception $e) { - throw new \Exception($e->getMessage()); + throw new \Exception($e->getMessage(), $e->getCode()); } } @@ -110,21 +118,21 @@ private function validateAndPrepare(User $user, Product $product, mixed $data): throw new \Exception( sprintf( 'User do not have the required amount of %s to use this product!', - $this->generalSettings->credits_display_name + $this->generalSettings()->credits_display_name ) ); } // General checks for user. - if (!$user->hasVerifiedEmail() && $this->userSettings->force_email_verification) { + if (!$user->hasVerifiedEmail() && $this->userSettings()->force_email_verification) { throw new \Exception('User must verify their email before creating a server.'); } - if (!$user->discordUser && $this->userSettings->force_discord_verification) { + if (!$user->discordUser && $this->userSettings()->force_discord_verification) { throw new \Exception('User must link their Discord account before creating a server.'); } - if ($user->cannot("admin.servers.bypass_creation_enabled") && !$this->serverSettings->creation_enabled) { + if ($user->cannot("admin.servers.bypass_creation_enabled") && !$this->serverSettings()->creation_enabled) { throw new \Exception('Server creation is currently disabled.'); } @@ -135,7 +143,7 @@ private function validateAndPrepare(User $user, Product $product, mixed $data): throw new \Exception('No available nodes for this product in the selected location.'); } - $allocationId = $this->pterodactylClient->getFreeAllocationId($availableNode); + $allocationId = $this->pterodactylClient()->getFreeAllocationId($availableNode); if (!$allocationId) { throw new \Exception('No free allocation available on the selected node.'); @@ -154,9 +162,33 @@ private function findAvailableNode(string $locationId, Product $product): ?Node ->get(); $availableNodes = $nodes->reject(function ($node) use ($product) { - return !$this->pterodactylClient->checkNodeResources($node, $product->memory, $product->disk); + return !$this->pterodactylClient()->checkNodeResources($node, $product->memory, $product->disk); }); return $availableNodes->isEmpty() ? null : $availableNodes->first(); } + + private function userSettings(): UserSettings + { + return $this->userSettings ??= app(UserSettings::class); + } + + private function generalSettings(): GeneralSettings + { + return $this->generalSettings ??= app(GeneralSettings::class); + } + + private function serverSettings(): ServerSettings + { + return $this->serverSettings ??= app(ServerSettings::class); + } + + private function pterodactylClient(): PterodactylClient + { + if ($this->pterodactylClient === null) { + $this->pterodactylClient = app(PterodactylClient::class, [app(PterodactylSettings::class)]); + } + + return $this->pterodactylClient; + } } diff --git a/app/Services/ServerUpgradeService.php b/app/Services/ServerUpgradeService.php index 81e1a8258..951bfeff7 100644 --- a/app/Services/ServerUpgradeService.php +++ b/app/Services/ServerUpgradeService.php @@ -9,19 +9,17 @@ use App\Models\Pterodactyl\Node; use App\Settings\PterodactylSettings; use Carbon\CarbonInterval; +use Illuminate\Support\Facades\DB; class ServerUpgradeService { - private PterodactylSettings $pterodactylSettings; - private PterodactylClient $pterodactylClient; + private ?PterodactylClient $pterodactylClient = null; /** * Create a new class instance. */ public function __construct() { - $this->pterodactylSettings = app(PterodactylSettings::class); - $this->pterodactylClient = app(PterodactylClient::class, [$this->pterodactylSettings]); } /** @@ -37,89 +35,123 @@ public function __construct() public function handle(User $user, Product $product, Server $server): Server { try { - $this->validateAndPrepare($user, $product, $server); + return DB::transaction(function () use ($user, $product, $server) { + $lockedUser = User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail(); + $lockedServer = Server::query()->whereKey($server->id)->lockForUpdate()->firstOrFail(); + $lockedServer->loadMissing('product'); + $currentProduct = $lockedServer->product; - $pterodactylServer = $this->pterodactylClient->getServerAttributes($server->pterodactyl_id); + if (! $currentProduct instanceof Product) { + throw new \Exception('The current server product could not be resolved.', 500); + } - $pterodactylServerNodeId = $pterodactylServer['relationships']['node']['attributes']['id']; - $node = Node::findOrFail($pterodactylServerNodeId); + $finalPrice = $this->calculateFinalPrice($lockedUser, $product, $lockedServer); - // Check if the new product can be applied to the server. - $requiredMemory = $product->memory - $server->product->memory; - $requiredDisk = $product->disk - $server->product->disk; + $pterodactylServer = $this->pterodactylClient()->getServerAttributes($lockedServer->pterodactyl_id); + $pterodactylServerNodeId = data_get($pterodactylServer, 'relationships.node.attributes.id'); - if (!$this->pterodactylClient->checkNodeResources($node, $requiredMemory, $requiredDisk)) { - throw new \Exception('Insufficient resources on the node to upgrade the server.', 422); - } + if (! is_int($pterodactylServerNodeId) && ! ctype_digit((string) $pterodactylServerNodeId)) { + throw new \Exception('Failed to resolve the current Pterodactyl node for this server.', 500); + } - $pterodactylServerAllocation = $pterodactylServer['allocation']; + $pterodactylServerNodeId = (int) $pterodactylServerNodeId; + $node = Node::findOrFail($pterodactylServerNodeId); + $currentEggId = data_get($pterodactylServer, 'egg'); - $updateServerResponse = $this->pterodactylClient->updateServerBuild($server->pterodactyl_id, $pterodactylServerAllocation, $product); - - if ($updateServerResponse->failed()) { - logger()->error('Failed to update server on Pterodactyl', [ - 'pterodactyl_id' => $server->pterodactyl_id, - 'status' => $updateServerResponse->status(), - 'error' => $updateServerResponse->json() - ]); + if (! $product->nodes()->whereKey($node->id)->exists()) { + throw new \Exception('Selected product is not available on the current node.', 422); + } + + if ($currentEggId !== null && ! $product->eggs()->whereKey($currentEggId)->exists()) { + throw new \Exception('Selected product is not compatible with the current egg.', 422); + } + + $requiredMemory = $product->memory - $currentProduct->memory; + $requiredDisk = $product->disk - $currentProduct->disk; + + if (! $this->pterodactylClient()->checkNodeResources($node, $requiredMemory, $requiredDisk)) { + throw new \Exception('Insufficient resources on the node to upgrade the server.', 422); + } - $server->delete(); + $pterodactylServerAllocation = data_get($pterodactylServer, 'allocation'); - throw new \Exception( - sprintf( - 'Failed to update server on Pterodactyl: %s', - $updateServerResponse->json()['errors'][0]['detail'] ?? 'Unknown error' - ) - ); - } + if (! is_int($pterodactylServerAllocation) && ! ctype_digit((string) $pterodactylServerAllocation)) { + throw new \Exception('Failed to resolve the current Pterodactyl allocation for this server.', 500); + } - $powerActionResponse = $this->pterodactylClient->powerAction($server, 'restart'); + $pterodactylServerAllocation = (int) $pterodactylServerAllocation; - if ($powerActionResponse->failed()) { - logger()->error('Failed to restart server on Pterodactyl', [ - 'pterodactyl_id' => $server->pterodactyl_id, - 'status' => $powerActionResponse->status(), - 'error' => $powerActionResponse->json() + if ($finalPrice > 0) { + $lockedUser->decrement('credits', $finalPrice); + } elseif ($finalPrice < 0) { + $lockedUser->increment('credits', abs($finalPrice)); + } + + $lockedServer->update([ + 'product_id' => $product->id, + 'last_billed' => now(), + 'canceled' => null ]); - throw new \Exception( - sprintf( - 'Failed to restart server on Pterodactyl: %s', - $powerActionResponse->json()['errors'][0]['detail'] ?? 'Unknown error' - ) - ); - } - - $server->update([ - 'product_id' => $product->id, - 'last_billed' => now(), - 'canceled' => null - ]); - - return $server; + $updateServerResponse = $this->pterodactylClient()->updateServerBuild($lockedServer->pterodactyl_id, $pterodactylServerAllocation, $product); + + if ($updateServerResponse->failed()) { + logger()->error('Failed to update server on Pterodactyl', [ + 'pterodactyl_id' => $lockedServer->pterodactyl_id, + 'status' => $updateServerResponse->status(), + 'error' => $updateServerResponse->json() + ]); + + throw new \Exception( + 'Failed to update server on Pterodactyl.', + 500 + ); + } + + $powerActionResponse = $this->pterodactylClient()->powerAction($lockedServer, 'restart'); + + if ($powerActionResponse->failed()) { + try { + $this->pterodactylClient()->updateServerBuild($lockedServer->pterodactyl_id, $pterodactylServerAllocation, $currentProduct); + } catch (\Throwable $rollbackException) { + logger()->error('Failed to roll back server build after restart failure.', [ + 'pterodactyl_id' => $lockedServer->pterodactyl_id, + 'error' => $rollbackException->getMessage(), + ]); + } + + logger()->error('Failed to restart server on Pterodactyl', [ + 'pterodactyl_id' => $lockedServer->pterodactyl_id, + 'status' => $powerActionResponse->status(), + 'error' => $powerActionResponse->json() + ]); + + throw new \Exception( + 'Failed to restart server on Pterodactyl.', + 500 + ); + } + + return $lockedServer; + }); } catch (\Exception $e) { throw new \Exception($e->getMessage(), $e->getCode()); } } - private function validateAndPrepare(User $user, Product $product, Server $server): void + private function calculateFinalPrice(User $user, Product $product, Server $server): int { - // Check if user has enough credits to upgrade the server. $billingPeriodSeconds = $this->getSecondsFromBillingPeriod($product); - $timeUsed = now()->diffInSeconds($server->last_billed, true); - $refundAmount = $server->product->price - ($server->product->price * ($timeUsed / $billingPeriodSeconds)); + $timeUsed = min(now()->diffInSeconds($server->last_billed, true), $billingPeriodSeconds); + $unusedRatio = max(0, ($billingPeriodSeconds - $timeUsed) / $billingPeriodSeconds); + $refundAmount = (int) round($server->product->price * $unusedRatio); + $finalPrice = $product->price - $refundAmount; - if ($user->credits < ($product->price - $refundAmount)) { + if ($finalPrice > 0 && $user->credits < $finalPrice) { throw new \Exception('Insufficient credits to upgrade the server.', 422); } - // Refund the user for the unused time on the current product. - $finalPrice = $product->price - $refundAmount; - if ($finalPrice > 0) { - $user->decrement('credits', $finalPrice); - } elseif ($finalPrice < 0) { - $user->increment('credits', abs($finalPrice)); - } + return (int) round($finalPrice); } private function getSecondsFromBillingPeriod(Product $product): int @@ -135,4 +167,13 @@ private function getSecondsFromBillingPeriod(Product $product): int default => CarbonInterval::hour()->totalSeconds, }; } + + private function pterodactylClient(): PterodactylClient + { + if ($this->pterodactylClient === null) { + $this->pterodactylClient = app(PterodactylClient::class, [app(PterodactylSettings::class)]); + } + + return $this->pterodactylClient; + } } diff --git a/app/Settings/DiscordSettings.php b/app/Settings/DiscordSettings.php index 7381b90c9..8b3593af7 100644 --- a/app/Settings/DiscordSettings.php +++ b/app/Settings/DiscordSettings.php @@ -21,6 +21,14 @@ public static function group(): string return 'discord'; } + public static function encrypted(): array + { + return [ + 'bot_token', + 'client_secret', + ]; + } + /** * Summary of validations array * @return array @@ -52,7 +60,7 @@ public static function getOptionInputData() 'position' => 5, 'bot_token' => [ 'label' => 'Bot Token', - 'type' => 'string', + 'type' => 'password', 'description' => 'The bot token for your Discord bot.', ], 'client_id' => [ @@ -62,7 +70,7 @@ public static function getOptionInputData() ], 'client_secret' => [ 'label' => 'Client Secret', - 'type' => 'string', + 'type' => 'password', 'description' => 'The client secret for your Discord bot.', ], 'guild_id' => [ diff --git a/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php index 6cc654504..660e3450e 100644 --- a/app/Settings/GeneralSettings.php +++ b/app/Settings/GeneralSettings.php @@ -27,6 +27,13 @@ public static function group(): string return 'general'; } + public static function encrypted(): array + { + return [ + 'recaptcha_secret_key', + ]; + } + /** @@ -133,7 +140,7 @@ public static function getOptionInputData() 'description' => 'The site key for reCAPTCHA.' ], 'recaptcha_secret_key' => [ - 'type' => 'string', + 'type' => 'password', 'label' => 'reCAPTCHA Secret Key', 'description' => 'The secret key for reCAPTCHA.' ], diff --git a/app/Settings/MailSettings.php b/app/Settings/MailSettings.php index ecc767b05..df83c3a4c 100644 --- a/app/Settings/MailSettings.php +++ b/app/Settings/MailSettings.php @@ -19,7 +19,6 @@ public static function group(): string { return 'mail'; } - /* public static function encrypted(): array { @@ -27,7 +26,6 @@ public static function encrypted(): array 'mail_password', ]; } -*/ public function setConfig() { try { diff --git a/app/Settings/PterodactylSettings.php b/app/Settings/PterodactylSettings.php index 297255b22..90e7c30b8 100644 --- a/app/Settings/PterodactylSettings.php +++ b/app/Settings/PterodactylSettings.php @@ -6,8 +6,8 @@ class PterodactylSettings extends Settings { - public string $admin_token = ''; - public string $user_token = ''; + public ?string $admin_token = null; + public ?string $user_token = null; public string $panel_url = ''; public int $per_page_limit = 50; @@ -15,7 +15,7 @@ public static function group(): string { return 'pterodactyl'; } -/* + public static function encrypted(): array { return [ @@ -23,7 +23,6 @@ public static function encrypted(): array 'user_token', ]; } -*/ /** * Get url with ensured ending backslash * @@ -42,8 +41,8 @@ public static function getValidations() { return [ 'panel_url' => 'required|string|url', - 'admin_token' => 'required|string', - 'user_token' => 'required|string', + 'admin_token' => 'nullable|string', + 'user_token' => 'nullable|string', 'per_page_limit' => 'required|integer|min:1|max:10000', ]; } @@ -65,12 +64,12 @@ public static function getOptionInputData() ], 'admin_token' => [ 'label' => 'Admin Token', - 'type' => 'string', + 'type' => 'password', 'description' => 'The admin user token for your Pterodactyl panel.', ], 'user_token' => [ 'label' => 'User Token', - 'type' => 'string', + 'type' => 'password', 'description' => 'The user token for your Pterodactyl panel.', ], 'per_page_limit' => [ diff --git a/app/Support/HtmlSanitizer.php b/app/Support/HtmlSanitizer.php new file mode 100644 index 000000000..4058f7151 --- /dev/null +++ b/app/Support/HtmlSanitizer.php @@ -0,0 +1,63 @@ +