Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5eb7b59
bugfix: reinstate dash cache invalidation fixes and use better cachelock
simbabimba-dev Jan 28, 2026
fa009de
bugfix: reinstate referral abuse trace mechanism
simbabimba-dev Jan 28, 2026
aceb209
feat: remove manual logging and let tapActivity handle api mod reason…
simbabimba-dev Jan 28, 2026
6ebfc73
bugfix: remove manual logging, handover to tapactivity, restore dockb…
simbabimba-dev Jan 28, 2026
017d1fc
bugfix: fix initial creds being 1000x the db value due reapplying pre…
simbabimba-dev Jan 28, 2026
a497f08
feat: let tapactivity do the api mod reason logging
simbabimba-dev Jan 28, 2026
569b35b
bugfix: fix referral return pointing to unknown method instead of rec…
simbabimba-dev Jan 28, 2026
0ecafdd
fix: make sure cache lock is always released
simbabimba-dev Jan 28, 2026
661c384
bugfix: ensure credits are always formatted as strings in activity logs
simbabimba-dev Jan 28, 2026
839b22d
bugfix: simplify 'Via' attribute assignment for web requests in Serve…
simbabimba-dev Jan 28, 2026
3742890
bugfix: change referral ID from 'N/A' to null for better data handling
simbabimba-dev Jan 28, 2026
eeb2530
bugfix: remove unnecessary local variable assignment for reason in se…
simbabimba-dev Jan 28, 2026
7f6a3fe
bugfix: optimize referral retrieval and deletion logic in UserControl…
simbabimba-dev Jan 29, 2026
ccbeefa
fix: better coupon handling and use max uses per user limit locally
simbabimba-dev Jan 30, 2026
8d75875
bugfix: correct handling of minimum credits for server unsuspension t…
simbabimba-dev Jan 30, 2026
e451422
bugfix: enhance locale handling and formatting in CurrencyHelper; fix…
simbabimba-dev Jan 30, 2026
49aca6e
bugfix: implement atomic credit deduction and post-creation job for s…
simbabimba-dev Jan 30, 2026
9a74a66
bugfix: optimize coupon usage increment logic to handle race conditio…
simbabimba-dev Jan 31, 2026
515e753
bugfix: enhance locale-aware formatting in CurrencyHelper; improve us…
simbabimba-dev Jan 31, 2026
d4a0f19
bugfix: add unique constraint to user_coupons; consolidate duplicate …
simbabimba-dev Feb 2, 2026
788e29c
bugfix: implement server reconciliation job; enhance server creation …
simbabimba-dev Feb 2, 2026
19f02eb
refactor: remove global setting for minimum credit and use local mini…
simbabimba-dev Feb 2, 2026
673d9ab
fix users being debited 2x
simbabimba-dev Feb 2, 2026
179c84e
docs: refactor Old documentation links
simbabimba-dev Feb 3, 2026
e648f68
Update README.md
simbabimba-dev Feb 3, 2026
e654807
Fix installation instructions link in README
simbabimba-dev Feb 3, 2026
de3ccf6
Merge pull request #9 from LakshmiBhaskarPVL/old-doc-links
simbabimba-dev Feb 3, 2026
a5d295e
create minimum credits old column and let admins be able to revert mi…
simbabimba-dev Feb 3, 2026
7bdd64c
refactor: fix activity log deletion query in RegisterController (issu…
simbabimba-dev Feb 3, 2026
bcfcd21
refactor: replace DB statements with schema facade
simbabimba-dev Feb 4, 2026
7f0ffc8
fix: required variables swal2 modal having random html in it
simbabimba-dev Feb 4, 2026
5d691a9
refactor: update minimum credits logic to handle default value case
simbabimba-dev Feb 4, 2026
cb9ac75
fix the incorrect formatting for required var model made by laravel b…
simbabimba-dev Feb 4, 2026
0475dad
refactor: update product credit checks to use effective minimum credits
simbabimba-dev Feb 4, 2026
5b84649
refactor: actually update local usercredits tracker alongside db upda…
simbabimba-dev Feb 4, 2026
cd92506
Update app/Models/Product.php
simbabimba-dev Feb 4, 2026
3504834
refactor: remove global setting for minimum credit and use local mini…
simbabimba-dev Feb 5, 2026
23fb69b
Merge development into PR branch with our changes preserved
simbabimba-dev Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,17 @@ more info: [Docker](https://github.com/Ctrlpanel-gg/panel/blob/main/.github/dock
Requirements:

- Platform
- Major Linux distros such as Debian, Ubuntu, CentOS, Fedora, and ArchLinux etc.
- Major Linux distros such as Debian, Ubuntu, CentOS, Fedora, and ArchLinux etc.

Follow the [documentation](https://ctrlpanel.gg/docs/intro) to know how to install.
Follow the [documentation](https://ctrlpanel.gg/docs) to know how to install.

### MarketPlace

If you need more functionality, check out [Marketplace](https://market.ctrlpanel.gg/).

## 🆙 How to Update

Please read: [Update Instructions](https://ctrlpanel.gg/docs/Installation/updating)
Please read: [Update Instructions](https://ctrlpanel.gg/docs/category/updating)

## 🆕 What's Next?

Expand All @@ -85,4 +85,4 @@ Thanks to all contributors and supporters!

## ♥️ Donations

If you like what we do, please consider [supporting](https://ctrlpanel.gg/docs/Contributing/donating) us.
If you like what we do, please consider [supporting](https://ctrlpanel.gg/docs/contributing/donating) us.
28 changes: 28 additions & 0 deletions app/Classes/PterodactylClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,34 @@ public function getServers()
return $response->json()['data'];
}

/**
* Find a server on Pterodactyl by its external_id (our local server id).
* This paginates through servers until a matching external_id is found.
* Returns the server attributes array or null if not found.
*/
public function findServerByExternalId(string $externalId)
{
// Use the dedicated external lookup endpoint for efficiency and correctness
try {
$response = $this->application->get("application/servers/external/{$externalId}?include=egg,node,nest,location,allocations,user");
} catch (Exception $e) {
Log::error('Failed to query pterodactyl server by external_id: ' . $e->getMessage());
return null;
}

if ($response->failed()) {
// 404 -> server not found
if ($response->status() === 404) {
return null;
}

Log::error('Failed to query pterodactyl server by external_id: HTTP ' . $response->status());
throw self::getException('Failed to get server by external_id', $response->status());
}

return $response->json()['attributes'];
}

/**
* @return null
*
Expand Down
110 changes: 106 additions & 4 deletions app/Helpers/CurrencyHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,102 @@ public function convertForDisplay($amount)
return $amount / 1000;
}

public function formatForDisplay($amount, $decimals = 2)
/**
* Gets the effective locale to use for formatting, considering global overrides.
*
* @param string|null $locale The requested locale
* @param bool $ignoreOverride Whether to ignore the global override setting
* @return string The effective locale to use
*/
// Simple cache to avoid resolving settings repeatedly in tight loops
private static array $effectiveLocaleCache = [];

private function getEffectiveLocale($locale = null, $ignoreOverride = false)
{
$cacheKey = ($locale ?? '') . '|' . ($ignoreOverride ? '1' : '0');
if (isset(self::$effectiveLocaleCache[$cacheKey])) {
return self::$effectiveLocaleCache[$cacheKey];
}

$effectiveLocale = $locale ?: str_replace('_', '-', app()->getLocale());

if (!$ignoreOverride) {
$override = resolve(\App\Settings\GeneralSettings::class)->currency_format_override ?? null;
if ($override) {
$effectiveLocale = $override;
}
}

// normalize (e.g., bg_BG -> bg-BG)
$effectiveLocale = str_replace('_', '-', $effectiveLocale);

self::$effectiveLocaleCache[$cacheKey] = $effectiveLocale;

return $effectiveLocale;
}

/**
* Formats a currency amount for display.
*
* @param mixed $amount The amount to format.
* @param int $decimals Number of decimal places to use.
* @param string|null $locale The locale to use for formatting (defaults to current application locale).
* @param bool $ignoreOverride When true, bypasses the global currency format override setting.
* @return string The formatted currency string.
*/
public function formatForDisplay($amount, $decimals = 2, $locale = null, $ignoreOverride = false)
{
return number_format($this->convertForDisplay($amount), $decimals, ',', '.');
$locale = $this->getEffectiveLocale($locale, $ignoreOverride);

$display = $this->convertForDisplay($amount);

// For Bulgarian ('bg'), Spanish ('es'), and Polish ('pl') locales: For small numbers, prefer comma as decimal separator and no thousands separator.
// Accept locale variants like 'bg-BG' by matching the prefix.
$specialLocales = ['bg', 'es', 'pl'];
$prefix = strtolower(explode('-', $locale)[0]);
if (in_array($prefix, $specialLocales, true) && $display <= 9999) {
return number_format($display, $decimals, ',', '');
}

// Wrap intl usage in try/catch so environments without the PHP intl extension don't crash.
try {
$formatter = new NumberFormatter($locale, NumberFormatter::DECIMAL);
$formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $decimals);
$formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $decimals);
$result = $formatter->format($display);
if ($result === false) {
throw new \RuntimeException('NumberFormatter failed to format');
}

return $result;
} catch (\Throwable $e) {
// Fallback: simple locale-aware formatting without relying on intl.
// Determine locale prefix (e.g., "bg-BG" -> "bg")
$prefix = strtolower(explode('-', (string) $locale)[0]);

// Locales that commonly use comma as decimal separator
$europeanStyleLocales = ['bg', 'cs', 'da', 'de', 'es', 'fi', 'fr', 'hu', 'it', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'tr'];

$decimalSeparator = '.';
$thousandsSeparator = ',';
if (in_array($prefix, $europeanStyleLocales, true)) {
$decimalSeparator = ',';
$thousandsSeparator = '.';
}

try {
logger()->warning('CurrencyHelper::formatForDisplay fell back to PHP number_format', [
'locale' => $locale,
'prefix' => $prefix,
'decimals' => $decimals,
'error' => $e->getMessage(),
]);
} catch (\Throwable $logEx) {
// swallow logging errors to avoid impacting formatting
}

return number_format($display, $decimals, $decimalSeparator, $thousandsSeparator);
}
}

public function formatForForm($amount, $decimals = 2)
Expand All @@ -30,8 +123,17 @@ public function formatToCurrency(int $amount, $currency_code, $locale = null,)
{
$locale = $locale ?: str_replace('_', '-', app()->getLocale());

$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
try {
$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
$result = $formatter->formatCurrency($this->convertForDisplay($amount), $currency_code);
if ($result === false) {
throw new \RuntimeException('NumberFormatter failed to format currency');
}

return $formatter->formatCurrency($this->convertForDisplay($amount), $currency_code);
return $result;
} catch (\Throwable $e) {
// Fallback: return a simple formatted number with the currency code appended
return number_format($this->convertForDisplay($amount), 2, '.', ',') . ' ' . strtoupper($currency_code);
}
}
}
24 changes: 23 additions & 1 deletion app/Http/Controllers/Admin/CouponController.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public function store(Request $request)
'type' => $request->input('type'),
'value' => $request->input('value'),
'max_uses' => $request->input('max_uses'),
// Per-coupon max_uses_per_user is REQUIRED: -1 = unlimited, >=1 = limit
'max_uses_per_user' => $request->input('max_uses_per_user'),
'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()
Expand Down Expand Up @@ -165,10 +167,30 @@ private function requestRules(Request $request)
$random_codes_amount = $request->input('range_codes');
$rules = [
"type" => "required|string|in:percentage,amount",
"max_uses" => "required|integer|digits_between:1,100",
// Maximum number of uses globally. Set to -1 for unlimited, or between 1 and 100 digits.
"max_uses" => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != -1 && ($value <= 0 || strlen((string) $value) > 100)) {
$fail(__('Maximum number of uses. Set to -1 for unlimited or a positive integer with at most 100 digits.'));
}
}
],
// Per-coupon per-user limit (REQUIRED). -1 = unlimited per user, >=1 = enforce count
"max_uses_per_user" => [
'required',
'integer',
function ($attribute, $value, $fail) {
if ($value != -1 && $value < 1) {
$fail(__('Max uses per user must be -1 for unlimited or a positive integer >= 1.'));
}
}
],
"value" => "required|numeric|between:0,100",
"expires_at" => "nullable|date|after:" . Carbon::now()->format(Coupon::formatDate())
Comment thread
simbabimba-dev marked this conversation as resolved.
];
// Per-coupon max_uses_per_user is now REQUIRED by policy (single source of truth)

if ($coupon_code) {
$rules['code'] = "required|string|min:4";
Expand Down
13 changes: 7 additions & 6 deletions app/Http/Controllers/Admin/ProductController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public function store(Request $request)
'swap' => 'required|numeric|max:1000000|min:0',
'description' => 'required|string|max:191',
'disk' => 'required|numeric|max:1000000|min:0',
'minimum_credits' => 'nullable|numeric|max:1000000',
'minimum_credits' => 'nullable|numeric|max:1000000|gte:price',
'io' => 'required|numeric|max:1000000|min:0',
'serverlimit' => 'required|numeric|max:1000000|min:0',
'databases' => 'required|numeric|max:1000000|min:0',
Expand Down Expand Up @@ -117,13 +117,13 @@ public function store(Request $request)
* @param Product $product
* @return Application|Factory|View
*/
public function show(Product $product, UserSettings $user_settings, GeneralSettings $general_settings)
public function show(Product $product, GeneralSettings $general_settings)
{
$this->checkAnyPermission([self::READ_PERMISSION,self::WRITE_PERMISSION]);

return view('admin.products.show', [
'product' => $product,
'minimum_credits' => $user_settings->min_credits_to_make_server,
'minimum_credits' => $product->minimum_credits ?? $product->price,
'credits_display_name' => $general_settings->credits_display_name
]);
}
Expand Down Expand Up @@ -164,7 +164,7 @@ public function update(Request $request, Product $product): RedirectResponse
'description' => 'required|string|max:191',
'disk' => 'required|numeric|max:1000000|min:5',
'io' => 'required|numeric|max:1000000|min:0',
'minimum_credits' => 'nullable|numeric|max:1000000',
'minimum_credits' => 'nullable|numeric|max:1000000|gte:price',
'databases' => 'required|numeric|max:1000000|min:0',
'serverlimit' => 'required|numeric|max:1000000|min:0',
'backups' => 'required|numeric|max:1000000|min:0',
Expand Down Expand Up @@ -274,8 +274,9 @@ public function dataTable()
->editColumn('price', function (Product $product, CurrencyHelper $currencyHelper) {
return $currencyHelper->formatForDisplay($product->price);
})
->editColumn('minimum_credits', function (Product $product, UserSettings $user_settings, CurrencyHelper $currencyHelper) {
return $product->minimum_credits ? $currencyHelper->formatForDisplay($product->minimum_credits) : $currencyHelper->formatForDisplay($user_settings->min_credits_to_make_server);
->editColumn('minimum_credits', function (Product $product, CurrencyHelper $currencyHelper) {
$value = $product->minimum_credits ?? $product->price;
return $currencyHelper->formatForDisplay($value);
})
->editColumn('serverlimit', function (Product $product) {
return $product->serverlimit == 0 ? "∞" : $product->serverlimit;
Expand Down
17 changes: 15 additions & 2 deletions app/Http/Controllers/Admin/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,21 @@ public function update(Request $request)
}

$nullable = $rpType->allowsNull();
if ($nullable) $settingsClass->$key = $request->input($key) ?? null;
else $settingsClass->$key = $request->input($key);
$inputValue = $nullable ? ($request->input($key) ?? null) : $request->input($key);

// using currency facade for reward and other currency fields
$currencyKeys = [
'reward',
'credits_reward_after_verify_discord',
'credits_reward_after_verify_email',
'initial_credits',
];

if (in_array($key, $currencyKeys) && $inputValue !== null && $inputValue !== '') {
$inputValue = Currency::prepareForDatabase($inputValue);
}

$settingsClass->$key = $inputValue;
}
$settingsClass->save();

Expand Down
64 changes: 56 additions & 8 deletions app/Http/Controllers/Admin/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,64 @@ public function show(User $user, LocaleSettings $locale_settings, GeneralSetting
{
$this->checkPermission(self::READ_PERMISSION);

//QUERY ALL REFERRALS A USER HAS
//i am not proud of this at all.
// QUERY ALL REFERRALS A USER HAS (efficiently)
$referralRecords = DB::table('user_referrals')->where('referral_id', '=', $user->id)->get();
$allReferrals = [];
$referrals = DB::table('user_referrals')->where('referral_id', '=', $user->id)->get();

foreach ($referrals as $referral) {
array_push($allReferrals, User::query()->findOrFail($referral->registered_user_id));
// Collect all registered_user_ids to avoid N+1 queries
$registeredIds = $referralRecords->pluck('registered_user_id')->filter()->unique()->values()->all();
$userMap = [];
if (!empty($registeredIds)) {
$userMap = User::withTrashed()->whereIn('id', $registeredIds)->get()->keyBy('id');
}

foreach ($referralRecords as $referral) {
$deleted = $referral->deleted_at !== null;

if ($deleted) {
$deletedId = $referral->deleted_user_id ?? null;
$name = $referral->deleted_username ? $referral->deleted_username . ' (deleted)' : 'Deleted User';

$allReferrals[] = (object)[
'id' => $deletedId,
'name' => $name,
'created_at' => \Carbon\Carbon::parse($referral->created_at),
'deleted' => true,
];

continue;
}

// If the referred user still exists, use their data
$registeredId = $referral->registered_user_id;
$userObj = $registeredId && isset($userMap[$registeredId]) ? $userMap[$registeredId] : null;

if ($userObj) {
$allReferrals[] = (object)[
'id' => $userObj->id,
'name' => $userObj->name,
'created_at' => $userObj->created_at,
'deleted' => false,
];
} else {
// fallback to deleted_user_id if present, otherwise mark as unknown
if ($referral->deleted_user_id) {
$allReferrals[] = (object)[
'id' => $referral->deleted_user_id,
'name' => ($referral->deleted_username ? $referral->deleted_username . ' (deleted)' : 'Deleted User'),
'created_at' => \Carbon\Carbon::parse($referral->created_at),
'deleted' => true,
];
} else {
$allReferrals[] = (object)[
'id' => null,
'name' => 'Unknown (deleted)',
'created_at' => \Carbon\Carbon::parse($referral->created_at),
'deleted' => true,
];
}
}
Comment thread
simbabimba-dev marked this conversation as resolved.
}

array_pop($allReferrals);

return view('admin.users.show')->with([
'user' => $user,
Expand Down Expand Up @@ -387,7 +435,7 @@ public function notify(Request $request)
try {
$user->notify(new DynamicNotification($data['via'], $database, $mail));
$successCount++;
} catch (\Throwable $e)
} catch (\Throwable $e) {
Log::error('Mass notification error for user ' . $user->id . ': ' . $e->getMessage());
}
}
Expand Down
Loading