commit
cc2328de02
45 changed files with 801 additions and 438 deletions
.env.example.gitignoreREADME.md
app
Classes
Console
Http/Controllers
Models
Notifications
database
factories
migrations
2021_06_23_090026_update_price_to_payments_table.php2021_06_23_090806_add__currency_code_to_payments_table.php
seeders
public/images
resources/views
admin
mail/payment
servers
store
routes
|
@ -36,8 +36,8 @@ PHPMYADMIN_URL=https://mysql.bitsec.dev
|
|||
DISCORD_INVITE_URL=https://discord.gg/vrUYdxG4wZ
|
||||
|
||||
#GOOGLE RECAPTCHA
|
||||
RECAPTCHA_SITE_KEY=YOUR_API_SITE_KEY
|
||||
RECAPTCHA_SECRET_KEY=YOUR_API_SECRET_KEY
|
||||
RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
|
||||
RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailhog
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -5,8 +5,9 @@
|
|||
/vendor
|
||||
/storage/credit_deduction_log
|
||||
.env
|
||||
.idea
|
||||
.env.testing
|
||||
.env.backup
|
||||
.idea
|
||||
.phpunit.result.cache
|
||||
docker-compose.override.yml
|
||||
Homestead.json
|
||||
|
|
211
README.md
211
README.md
|
@ -1,192 +1,25 @@
|
|||
# ControlPanel's Dashboard
|
||||
### Features
|
||||
|
||||
- PayPal Integration
|
||||
- Email Verification
|
||||
- Audit Log
|
||||
- Admin Dashboard
|
||||
- User/Server Management
|
||||
- Store (credit system)
|
||||
- and so much more!
|
||||
|
||||
# ControlPanel-gg
|
||||

|
||||
|
||||
     
|
||||
|
||||
## About
|
||||
ControlPanel's Dashboard is a dashboard application designed to offer clients a management tool to manage their pterodactyl servers. This dashboard comes with a credit-based billing solution that credits users hourly for each server they have and suspends them if they run out of credits.
|
||||
|
||||
This dashboard is built with Laravel as it offers a very robust and secure development environment.
|
||||
This dashboard offers an easy to use and free billing solution for all starting and experienced hosting providers. This dashboard has many customization options and added discord 0auth verification to offer a solid link between your discord server and your dashboard.
|
||||
|
||||
|
||||
I copied a part of pterodactyl’s documentation for the installation because it uses the same dependencies and covers this area pretty good.
|
||||
|
||||
|
||||
|
||||
## Dependencies
|
||||
* PHP `7.4` or `8.0` (recommended) with the following extensions: `cli`, `openssl`, `gd`, `mysql`, `PDO`, `mbstring`, `tokenizer`, `bcmath`, `xml` or `dom`, `curl`, `zip`, and `fpm` if you are planning to use NGINX.
|
||||
* MySQL `5.7.22` or higher (MySQL `8` recommended) **or** MariaDB `10.2` or higher.
|
||||
* Redis (`redis-server`)
|
||||
* A webserver (Apache, NGINX, Caddy, etc.)
|
||||
* `curl`
|
||||
* `tar`
|
||||
* `unzip`
|
||||
* `git`
|
||||
* `composer` v2
|
||||
|
||||
### Example Dependency Installation
|
||||
if you already have pterodactyl installed you can skip this step!
|
||||
|
||||
The commands below are simply an example of how you might install these dependencies. Please consult with your
|
||||
operating system's package manager to determine the correct packages to install.
|
||||
|
||||
``` bash
|
||||
# Add "add-apt-repository" command
|
||||
apt -y install software-properties-common curl apt-transport-https ca-certificates gnupg
|
||||
|
||||
# Add additional repositories for PHP, Redis, and MariaDB
|
||||
LC_ALL=C.UTF-8 add-apt-repository -y ppa:ondrej/php
|
||||
add-apt-repository -y ppa:chris-lea/redis-server
|
||||
curl -sS https://downloads.mariadb.com/MariaDB/mariadb_repo_setup | sudo bash
|
||||
|
||||
# Update repositories list
|
||||
apt update
|
||||
|
||||
# Add universe repository if you are on Ubuntu 18.04
|
||||
apt-add-repository universe
|
||||
|
||||
# Install Dependencies
|
||||
apt -y install php8.0 php8.0-{cli,gd,mysql,pdo,mbstring,tokenizer,bcmath,xml,fpm,curl,zip} mariadb-server nginx tar unzip git redis-server
|
||||
```
|
||||
### Extra dependency used on this dashboard
|
||||
you need to install this, use the appropriate php version (php -v)
|
||||
```bash
|
||||
sudo apt-get install php8.0-intl
|
||||
```
|
||||
|
||||
### Installing Composer
|
||||
if you already have pterodactyl installed you can skip this step!
|
||||
|
||||
Composer is a dependency manager for PHP that allows us to ship everything you'll need code wise to operate the Panel. You'll
|
||||
need composer installed before continuing in this process.
|
||||
|
||||
``` bash
|
||||
curl -sS https://getcomposer.org/installer | sudo php -- --install-dir=/usr/local/bin --filename=composer
|
||||
```
|
||||
|
||||
## Download Files
|
||||
The first step in this process is to create the folder where the panel will live and then move ourselves into that
|
||||
newly created folder. Below is an example of how to perform this operation.
|
||||
|
||||
``` bash
|
||||
mkdir -p /var/www/dashboard
|
||||
cd /var/www/dashboard
|
||||
```
|
||||
|
||||
``` bash
|
||||
git clone https://github.com/ControlPanel-gg/dashboard.git ./
|
||||
chmod -R 755 storage/* bootstrap/cache/
|
||||
```
|
||||
|
||||
## Installation
|
||||
Now that all of the files have been downloaded we need to configure some core aspects of the Panel.
|
||||
|
||||
You will need a database setup and a user with the correct permissions created for that database before
|
||||
continuing any further.
|
||||
|
||||
|
||||
First we will copy over our default environment settings file, install core dependencies, and then generate a
|
||||
new application encryption key.
|
||||
|
||||
``` bash
|
||||
cp .env.example .env
|
||||
composer install
|
||||
|
||||
# Only run the command below if you are installing this Panel
|
||||
php artisan key:generate --force
|
||||
|
||||
|
||||
# you should create a symbolic link from public/storage to storage/app/public
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
|
||||
Back up your encryption key (APP_KEY in the `.env` file). It is used as an encryption key for all data that needs to be stored securely (e.g. api keys).
|
||||
Store it somewhere safe - not just on your server. If you lose it all encrypted data is irrecoverable -- even if you have database backups.
|
||||
|
||||
### Environment Configuration
|
||||
Simply edit the .env to your needs
|
||||
|
||||
Please **do not** forget to enter the database creds in here, or the next step won't work
|
||||
Please **do not** forget to enter your pterodactyl api key in here, or the next steps won't work
|
||||
|
||||
``` bash
|
||||
nano .env
|
||||
```
|
||||
|
||||
### Database Setup
|
||||
Now we need to setup all of the base data for the Panel in the database you created earlier. **The command below
|
||||
may take some time to run depending on your machine. Please _DO NOT_ exit the process until it is completed!** This
|
||||
command will setup the database tables and then add all of the Nests & Eggs that power Pterodactyl.
|
||||
|
||||
``` bash
|
||||
php artisan migrate --seed --force
|
||||
```
|
||||
|
||||
### Add some example products
|
||||
This step is optional, only run this once
|
||||
``` bash
|
||||
php artisan db:seed --class=ExampleItemsSeeder --force
|
||||
```
|
||||
|
||||
### Add The First User
|
||||
``` bash
|
||||
php artisan make:user
|
||||
```
|
||||
|
||||
### Set Permissions
|
||||
The last step in the installation process is to set the correct permissions on the Panel files so that the webserver can
|
||||
use them correctly.
|
||||
|
||||
``` bash
|
||||
# If using NGINX or Apache (not on CentOS):
|
||||
chown -R www-data:www-data /var/www/dashboard/*
|
||||
|
||||
# If using NGINX on CentOS:
|
||||
chown -R nginx:nginx /var/www/dashboard/*
|
||||
|
||||
# If using Apache on CentOS
|
||||
chown -R apache:apache /var/www/dashboard/*
|
||||
```
|
||||
|
||||
### Crontab Configuration
|
||||
The first thing we need to do is create a new cronjob that runs every minute to process specific Dashboard tasks. like billing users hourly and suspending unpaid servers
|
||||
|
||||
```bash
|
||||
* * * * * php /var/www/dashboard/artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
|
||||
### Nginx
|
||||
You should paste the contents of the file below, replacing <domain> with your domain name being used in a file called dashboard.conf and place it in /etc/nginx/sites-available/, or — if on CentOS, /etc/nginx/conf.d/.
|
||||
|
||||
```bash
|
||||
server {
|
||||
listen 80;
|
||||
root /var/www/dashboard/public;
|
||||
index index.php index.html index.htm index.nginx-debian.html;
|
||||
server_name YOUR.DOMAIN.COM;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
include snippets/fastcgi-php.conf;
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
}
|
||||
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enable configuration
|
||||
The final step is to enable your NGINX configuration and restart it.
|
||||
|
||||
```bash
|
||||
# You do not need to symlink this file if you are using CentOS.
|
||||
sudo ln -s /etc/nginx/sites-available/dashboard.conf /etc/nginx/sites-enabled/dashboard.conf
|
||||
|
||||
# Check for nginx errors
|
||||
sudo nginx -t
|
||||
|
||||
# You need to restart nginx regardless of OS. only do this you haven't received any errors
|
||||
systemctl restart nginx
|
||||
```
|
||||
### [Installation](https://github.com/ControlPanel-gg/dashboard/wiki "Installation")
|
||||
### [Updating](https://github.com/ControlPanel-gg/dashboard/wiki/Updating "Updating")
|
||||
### [Discord](https://discord.gg/4Y6HjD2uyU "discord")
|
||||
### [Contributing](https://github.com/ControlPanel-gg/dashboard/wiki/Contributing "Contributing")
|
||||
### [Donating](https://github.com/ControlPanel-gg/dashboard/wiki#donating "Donating")
|
||||
|
|
|
@ -49,8 +49,12 @@ class Pterodactyl
|
|||
$response = self::getAllocations($node);
|
||||
$freeAllocations = [];
|
||||
|
||||
foreach ($response['data'] as $allocation) {
|
||||
if (!$allocation['attributes']['assigned']) array_push($freeAllocations, $allocation);
|
||||
if(isset($response['data'])){
|
||||
if (!empty($response['data'])) {
|
||||
foreach ($response['data'] as $allocation) {
|
||||
if (!$allocation['attributes']['assigned']) array_push($freeAllocations, $allocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $freeAllocations;
|
||||
|
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Classes\Pterodactyl;
|
||||
use App\Models\Product;
|
||||
use App\Models\Server;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ServersSuspendedNotification;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ChargeCreditsCommand extends Command
|
||||
|
@ -23,6 +24,13 @@ class ChargeCreditsCommand extends Command
|
|||
*/
|
||||
protected $description = 'Charge all users with active servers';
|
||||
|
||||
|
||||
/**
|
||||
* A list of users that have to be notified
|
||||
* @var array
|
||||
*/
|
||||
protected $usersToNotify = [];
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
|
@ -40,42 +48,54 @@ class ChargeCreditsCommand extends Command
|
|||
*/
|
||||
public function handle()
|
||||
{
|
||||
Server::chunk(10, function ($servers) {
|
||||
Server::whereNull('suspended')->chunk(10, function ($servers) {
|
||||
/** @var Server $server */
|
||||
foreach ($servers as $server) {
|
||||
|
||||
//ignore suspended servers
|
||||
if ($server->isSuspended()) {
|
||||
echo Carbon::now()->isoFormat('LLL') . " Ignoring suspended server";
|
||||
continue;
|
||||
}
|
||||
|
||||
//vars
|
||||
/** @var Product $product */
|
||||
$product = $server->product;
|
||||
/** @var User $user */
|
||||
$user = $server->user;
|
||||
$price = ($server->product->price / 30) / 24;
|
||||
|
||||
//remove credits or suspend server
|
||||
if ($user->credits >= $price) {
|
||||
$user->decrement('credits', $price);
|
||||
|
||||
//log
|
||||
echo Carbon::now()->isoFormat('LLL') . " [CREDIT DEDUCTION] Removed " . number_format($price, 2, '.', '') . " from user (" . $user->name . ") for server (" . $server->name . ")\n";
|
||||
|
||||
#charge credits / suspend server
|
||||
if ($user->credits >= $product->getHourlyPrice()) {
|
||||
$this->line("<fg=blue>{$user->name}</> Current credits: <fg=green>{$user->credits}</> Credits to be removed: <fg=red>{$product->getHourlyPrice()}</>");
|
||||
$user->decrement('credits', $product->getHourlyPrice());
|
||||
} else {
|
||||
$response = Pterodactyl::client()->post("/application/servers/{$server->pterodactyl_id}/suspend");
|
||||
try {
|
||||
#suspend server
|
||||
$this->line("<fg=yellow>{$server->name}</> from user: <fg=blue>{$user->name}</> has been <fg=red>suspended!</>");
|
||||
$server->suspend();
|
||||
|
||||
if ($response->successful()) {
|
||||
echo Carbon::now()->isoFormat('LLL') . " [CREDIT DEDUCTION] Suspended server (" . $server->name . ") from user (" . $user->name . ")\n";
|
||||
$server->update(['suspended' => now()]);
|
||||
} else {
|
||||
echo Carbon::now()->isoFormat('LLL') . " [CREDIT DEDUCTION] CRITICAL ERROR! Unable to suspend server (" . $server->name . ") from user (" . $user->name . ")\n";
|
||||
dump($response->json());
|
||||
#add user to notify list
|
||||
if (!in_array($user, $this->usersToNotify)) {
|
||||
array_push($this->usersToNotify, $user);
|
||||
}
|
||||
} catch (\Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return 'Charged credits for existing servers!\n';
|
||||
return $this->notifyUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function notifyUsers()
|
||||
{
|
||||
if (!empty($this->usersToNotify)) {
|
||||
/** @var User $user */
|
||||
foreach ($this->usersToNotify as $user) {
|
||||
$this->line("<fg=yellow>Notified user:</> <fg=blue>{$user->name}</>");
|
||||
$user->notify(new ServersSuspendedNotification());
|
||||
}
|
||||
}
|
||||
|
||||
#reset array
|
||||
$this->usersToNotify = array();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ class Kernel extends ConsoleKernel
|
|||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
$schedule->command('credits:charge')->hourly();
|
||||
$schedule->command('queue:work --once')->everyMinute();
|
||||
|
||||
//log cronjob activity
|
||||
$schedule->call(function () {
|
||||
|
|
|
@ -3,11 +3,17 @@
|
|||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Payment;
|
||||
use App\Models\PaypalProduct;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ConfirmPaymentNotification;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
@ -20,15 +26,11 @@ use PayPalHttp\HttpException;
|
|||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
protected $allowedAmounts = [
|
||||
'87',
|
||||
'350',
|
||||
'1000',
|
||||
'2000',
|
||||
'4000'
|
||||
];
|
||||
|
||||
public function index(){
|
||||
/**
|
||||
* @return Application|Factory|View
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return view('admin.payments.index')->with([
|
||||
'payments' => Payment::paginate(15)
|
||||
]);
|
||||
|
@ -51,15 +53,16 @@ class PaymentController extends Controller
|
|||
* @param PaypalProduct $paypalProduct
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
public function pay(Request $request , PaypalProduct $paypalProduct)
|
||||
public function pay(Request $request, PaypalProduct $paypalProduct)
|
||||
{
|
||||
$request = new OrdersCreateRequest();
|
||||
$request->prefer('return=representation');
|
||||
$request->body = [
|
||||
"intent" => "CAPTURE",
|
||||
"purchase_units" => [
|
||||
"intent" => "CAPTURE",
|
||||
"purchase_units" => [
|
||||
[
|
||||
"reference_id" => uniqid(),
|
||||
"description" => $paypalProduct->description,
|
||||
"amount" => [
|
||||
"value" => $paypalProduct->price,
|
||||
"currency_code" => strtoupper($paypalProduct->currency_code)
|
||||
|
@ -69,7 +72,8 @@ class PaymentController extends Controller
|
|||
"application_context" => [
|
||||
"cancel_url" => route('payment.cancel'),
|
||||
"return_url" => route('payment.success', ['product' => $paypalProduct->id]),
|
||||
'brand_name' => config('app.name', 'Laravel') ,
|
||||
'brand_name' => config('app.name', 'Laravel'),
|
||||
'shipping_preference' => 'NO_SHIPPING'
|
||||
]
|
||||
];
|
||||
|
||||
|
@ -120,7 +124,10 @@ class PaymentController extends Controller
|
|||
*/
|
||||
public function success(Request $laravelRequest)
|
||||
{
|
||||
/** @var PaypalProduct $paypalProduct */
|
||||
$paypalProduct = PaypalProduct::findOrFail($laravelRequest->input('product'));
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$request = new OrdersCaptureRequest($laravelRequest->input('token'));
|
||||
$request->prefer('return=representation');
|
||||
|
@ -130,32 +137,38 @@ class PaymentController extends Controller
|
|||
if ($response->statusCode == 201 || $response->statusCode == 200) {
|
||||
|
||||
//update credits
|
||||
Auth::user()->increment('credits', $paypalProduct->quantity);
|
||||
$user->increment('credits', $paypalProduct->quantity);
|
||||
|
||||
//update server limit
|
||||
if (Auth::user()->server_limit < 10) {
|
||||
Auth::user()->update(['server_limit' => 10]);
|
||||
if (Configuration::getValueByKey('SERVER_LIMIT_AFTER_IRL_PURCHASE', 10) !== 0) {
|
||||
if ($user->server_limit < Configuration::getValueByKey('SERVER_LIMIT_AFTER_IRL_PURCHASE', 10)) {
|
||||
$user->update(['server_limit' => 10]);
|
||||
}
|
||||
}
|
||||
|
||||
//update role
|
||||
if (Auth::user()->role == 'member') {
|
||||
Auth::user()->update(['role' => 'client']);
|
||||
if ($user->role == 'member') {
|
||||
$user->update(['role' => 'client']);
|
||||
}
|
||||
|
||||
//store payment
|
||||
Payment::create([
|
||||
'user_id' => Auth::user()->id,
|
||||
$payment = Payment::create([
|
||||
'user_id' => $user->id,
|
||||
'payment_id' => $response->result->id,
|
||||
'payer_id' => $laravelRequest->input('PayerID'),
|
||||
'type' => 'Credits',
|
||||
'status' => $response->result->status,
|
||||
'amount' => $paypalProduct->quantity,
|
||||
'price' => $paypalProduct->price,
|
||||
'currency_code' => $paypalProduct->currency_code,
|
||||
'payer' => json_encode($response->result->payer),
|
||||
]);
|
||||
|
||||
//payment notification
|
||||
$user->notify(new ConfirmPaymentNotification($payment));
|
||||
|
||||
//redirect back to home
|
||||
return redirect()->route('home')->with('success', 'Credits have been increased!');
|
||||
return redirect()->route('home')->with('success', 'Your credit balance has been increased!');
|
||||
}
|
||||
|
||||
// If call returns body in response, you can get the deserialized version from the result attribute of the response
|
||||
|
@ -183,6 +196,28 @@ class PaymentController extends Controller
|
|||
*/
|
||||
public function cancel(Request $request)
|
||||
{
|
||||
return redirect()->route('store.index')->with('success', 'Payment Canceled');
|
||||
return redirect()->route('store.index')->with('success', 'Payment was Cannceled');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return JsonResponse|mixed
|
||||
* @throws Exception
|
||||
*/
|
||||
public function dataTable()
|
||||
{
|
||||
$query = Payment::with('user');
|
||||
|
||||
return datatables($query)
|
||||
->editColumn('user', function (Payment $payment) {
|
||||
return $payment->user->name;
|
||||
})
|
||||
->editColumn('price', function (Payment $payment) {
|
||||
return $payment->formatCurrency();
|
||||
})
|
||||
->editColumn('created_at', function (Payment $payment) {
|
||||
return $payment->created_at ? $payment->created_at->diffForHumans() : '';
|
||||
})
|
||||
->make();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ class ProductController extends Controller
|
|||
"name" => "required|max:30",
|
||||
"price" => "required|numeric|max:1000000|min:0",
|
||||
"memory" => "required|numeric|max:1000000|min:5",
|
||||
"cpu" => "required|numeric|max:1000000|min:5",
|
||||
"cpu" => "required|numeric|max:1000000|min:0",
|
||||
"swap" => "required|numeric|max:1000000|min:0",
|
||||
"description" => "required",
|
||||
"disk" => "required|numeric|max:1000000|min:5",
|
||||
|
@ -103,7 +103,7 @@ class ProductController extends Controller
|
|||
"name" => "required|max:30",
|
||||
"price" => "required|numeric|max:1000000|min:0",
|
||||
"memory" => "required|numeric|max:1000000|min:5",
|
||||
"cpu" => "required|numeric|max:1000000|min:5",
|
||||
"cpu" => "required|numeric|max:1000000|min:0",
|
||||
"swap" => "required|numeric|max:1000000|min:0",
|
||||
"description" => "required",
|
||||
"disk" => "required|numeric|max:1000000|min:5",
|
||||
|
|
|
@ -8,9 +8,10 @@ use App\Models\Configuration;
|
|||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
|
@ -52,13 +53,21 @@ class RegisterController extends Controller
|
|||
*/
|
||||
protected function validator(array $data)
|
||||
{
|
||||
//check if ip has already made an account
|
||||
$data['ip'] = session()->get('ip') ?? request()->ip();
|
||||
if (User::where('ip', '=', request()->ip())->exists()) session()->put('ip', request()->ip());
|
||||
if (Configuration::getValueByKey('REGISTER_IP_CHECK', 'true') == 'true') {
|
||||
|
||||
//check if registered cookie exists as extra defense
|
||||
if (isset($_COOKIE['4b3403665fea6'])) {
|
||||
$data['registered'] = env('APP_ENV') == 'local' ? false : true;
|
||||
//check if ip has already made an account
|
||||
$data['ip'] = session()->get('ip') ?? request()->ip();
|
||||
if (User::where('ip', '=', request()->ip())->exists()) session()->put('ip', request()->ip());
|
||||
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'string', 'max:30', 'min:4', 'alpha_num', 'unique:users'],
|
||||
'email' => ['required', 'string', 'email', 'max:64', 'unique:users'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'g-recaptcha-response' => ['recaptcha'],
|
||||
'ip' => ['unique:users'],
|
||||
], [
|
||||
'ip.unique' => "You have already made an account with us! Please contact support if you think this is incorrect."
|
||||
]);
|
||||
}
|
||||
|
||||
return Validator::make($data, [
|
||||
|
@ -66,32 +75,28 @@ class RegisterController extends Controller
|
|||
'email' => ['required', 'string', 'email', 'max:64', 'unique:users'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
'g-recaptcha-response' => ['recaptcha'],
|
||||
'ip' => ['unique:users'],
|
||||
'registered' => ['nullable', 'boolean', 'in:true']
|
||||
], [
|
||||
'ip.unique' => "You have already made an account with us! Please contact support if you think this is incorrect.",
|
||||
'registered.in' => "You have already made an account with us! Please contact support if you think this is incorrect."
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @param array $data
|
||||
* @return User|\Illuminate\Http\RedirectResponse
|
||||
* @return User
|
||||
*/
|
||||
protected function create(array $data)
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'credits' => Configuration::getValueByKey('INITIAL_CREDITS'),
|
||||
'server_limit' => Configuration::getValueByKey('INITIAL_SERVER_LIMIT'),
|
||||
'credits' => Configuration::getValueByKey('INITIAL_CREDITS', 150),
|
||||
'server_limit' => Configuration::getValueByKey('INITIAL_SERVER_LIMIT', 1),
|
||||
'password' => Hash::make($data['password']),
|
||||
]);
|
||||
|
||||
$response = Pterodactyl::client()->post('/application/users', [
|
||||
"external_id" => (string)$user->id,
|
||||
"external_id" => App::environment('local') ? Str::random(16) : (string)$user->id,
|
||||
"username" => $user->name,
|
||||
"email" => $user->email,
|
||||
"first_name" => $user->name,
|
||||
|
@ -103,7 +108,6 @@ class RegisterController extends Controller
|
|||
|
||||
if ($response->failed()) {
|
||||
$user->delete();
|
||||
redirect()->route('register')->with('error', 'pterodactyl error');
|
||||
return $user;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,9 +18,6 @@ class HomeController extends Controller
|
|||
/** Show the application dashboard. */
|
||||
public function index(Request $request)
|
||||
{
|
||||
//set cookie as extra layer of defense against users that make multiple accounts
|
||||
setcookie('4b3403665fea6' , base64_encode(1) , time() + (20 * 365 * 24 * 60 * 60));
|
||||
|
||||
$usage = 0;
|
||||
|
||||
foreach (Auth::user()->Servers as $server){
|
||||
|
|
|
@ -12,11 +12,8 @@ use App\Models\Product;
|
|||
use App\Models\Server;
|
||||
use App\Notifications\ServerCreationError;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
|
||||
|
@ -33,23 +30,14 @@ class ServerController extends Controller
|
|||
/** Show the form for creating a new resource. */
|
||||
public function create()
|
||||
{
|
||||
//limit
|
||||
if (Auth::user()->Servers->count() >= Auth::user()->server_limit) {
|
||||
return redirect()->route('servers.index')->with('error', "You've already reached your server limit!");
|
||||
}
|
||||
|
||||
//minimum credits
|
||||
if (Auth::user()->credits <= Configuration::getValueByKey('MINIMUM_REQUIRED_CREDITS_TO_MAKE_SERVER' , 50)) {
|
||||
return redirect()->route('servers.index')->with('error', "You do not have the required amount of credits to create a new server!");
|
||||
}
|
||||
|
||||
if (!is_null($this->validateConfigurationRules())) return $this->validateConfigurationRules();
|
||||
|
||||
return view('servers.create')->with([
|
||||
'products' => Product::where('disabled' , '=' , false)->orderBy('price', 'asc')->get(),
|
||||
'locations' => Location::whereHas('nodes' , function ($query) {
|
||||
$query->where('disabled' , '=' , false);
|
||||
'products' => Product::where('disabled', '=', false)->orderBy('price', 'asc')->get(),
|
||||
'locations' => Location::whereHas('nodes', function ($query) {
|
||||
$query->where('disabled', '=', false);
|
||||
})->get(),
|
||||
'nests' => Nest::where('disabled' , '=' , false)->get(),
|
||||
'nests' => Nest::where('disabled', '=', false)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -57,22 +45,14 @@ class ServerController extends Controller
|
|||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
"name" => "required|max:191",
|
||||
"name" => "required|max:191",
|
||||
"description" => "nullable|max:191",
|
||||
"node_id" => "required|exists:nodes,id",
|
||||
"egg_id" => "required|exists:eggs,id",
|
||||
"product_id" => "required|exists:products,id",
|
||||
"node_id" => "required|exists:nodes,id",
|
||||
"egg_id" => "required|exists:eggs,id",
|
||||
"product_id" => "required|exists:products,id",
|
||||
]);
|
||||
|
||||
//limit validation
|
||||
if (Auth::user()->servers()->count() >= Auth::user()->server_limit) {
|
||||
return redirect()->route('servers.index')->with('error', 'Server limit reached!');
|
||||
}
|
||||
|
||||
//minimum credits
|
||||
if (Auth::user()->credits <= Configuration::getValueByKey('MINIMUM_REQUIRED_CREDITS_TO_MAKE_SERVER' , 50)) {
|
||||
return redirect()->route('servers.index')->with('error', "You do not have the required amount of credits to create a new server!");
|
||||
}
|
||||
if (!is_null($this->validateConfigurationRules())) return $this->validateConfigurationRules();
|
||||
|
||||
//create server
|
||||
$egg = Egg::findOrFail($request->input('egg_id'));
|
||||
|
@ -80,7 +60,7 @@ class ServerController extends Controller
|
|||
$node = Node::findOrFail($request->input('node_id'));
|
||||
|
||||
//create server on pterodactyl
|
||||
$response = Pterodactyl::createServer($server , $egg , $node);
|
||||
$response = Pterodactyl::createServer($server, $egg, $node);
|
||||
|
||||
if (is_null($response)) return $this->serverCreationFailed($server);
|
||||
if ($response->failed()) return $this->serverCreationFailed($server);
|
||||
|
@ -88,7 +68,7 @@ class ServerController extends Controller
|
|||
//update server with pterodactyl_id
|
||||
$server->update([
|
||||
'pterodactyl_id' => $response->json()['attributes']['id'],
|
||||
'identifier' => $response->json()['attributes']['identifier']
|
||||
'identifier' => $response->json()['attributes']['identifier']
|
||||
]);
|
||||
|
||||
return redirect()->route('servers.index')->with('success', 'server created');
|
||||
|
@ -103,13 +83,41 @@ class ServerController extends Controller
|
|||
return redirect()->route('servers.index')->with('error', 'No allocations satisfying the requirements for automatic deployment were found.');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return null|RedirectResponse
|
||||
*/
|
||||
private function validateConfigurationRules(){
|
||||
//limit validation
|
||||
if (Auth::user()->servers()->count() >= Auth::user()->server_limit) {
|
||||
return redirect()->route('servers.index')->with('error', 'Server limit reached!');
|
||||
}
|
||||
|
||||
//minimum credits
|
||||
if (Auth::user()->credits <= Configuration::getValueByKey('MINIMUM_REQUIRED_CREDITS_TO_MAKE_SERVER', 50)) {
|
||||
return redirect()->route('servers.index')->with('error', "You do not have the required amount of credits to create a new server!");
|
||||
}
|
||||
|
||||
//Required Verification for creating an server
|
||||
if (Configuration::getValueByKey('FORCE_EMAIL_VERIFICATION', 'false') === 'true' && !Auth::user()->hasVerifiedEmail()) {
|
||||
return redirect()->route('profile.index')->with('error', "You are required to verify your email address before you can create a server.");
|
||||
}
|
||||
|
||||
//Required Verification for creating an server
|
||||
if (Configuration::getValueByKey('FORCE_DISCORD_VERIFICATION', 'false') === 'true' && !Auth::user()->discordUser) {
|
||||
return redirect()->route('profile.index')->with('error', "You are required to link your discord account before you can create a server.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove the specified resource from storage. */
|
||||
public function destroy(Server $server)
|
||||
{
|
||||
try {
|
||||
$server->delete();
|
||||
return redirect()->route('servers.index')->with('success', 'server removed');
|
||||
} catch (\Exception $e) {
|
||||
} catch (Exception $e) {
|
||||
return redirect()->route('servers.index')->with('error', 'An exception has occurred while trying to remove a resource');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,9 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\PaypalProduct;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class StoreController extends Controller
|
||||
{
|
||||
|
@ -16,9 +13,21 @@ class StoreController extends Controller
|
|||
{
|
||||
$isPaypalSetup = false;
|
||||
if (env('PAYPAL_SECRET') && env('PAYPAL_CLIENT_ID')) $isPaypalSetup = true;
|
||||
if (env('APP_ENV', 'local') == 'local') $isPaypalSetup = true;
|
||||
|
||||
|
||||
//Required Verification for creating an server
|
||||
if (Configuration::getValueByKey('FORCE_EMAIL_VERIFICATION', false) === 'true' && !Auth::user()->hasVerifiedEmail()) {
|
||||
return redirect()->route('profile.index')->with('error', "You are required to verify your email address before you can purchase credits.");
|
||||
}
|
||||
|
||||
//Required Verification for creating an server
|
||||
if (Configuration::getValueByKey('FORCE_DISCORD_VERIFICATION', false) === 'true' && !Auth::user()->discordUser) {
|
||||
return redirect()->route('profile.index')->with('error', "You are required to link your discord account before you can purchase credits.");
|
||||
}
|
||||
|
||||
return view('store.index')->with([
|
||||
'products' => PaypalProduct::where('disabled' , '=' , false)->orderBy('price' , 'asc')->get(),
|
||||
'products' => PaypalProduct::where('disabled', '=', false)->orderBy('price', 'asc')->get(),
|
||||
'isPaypalSetup' => $isPaypalSetup
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ use Hidehalo\Nanoid\Client;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use NumberFormatter;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
class Payment extends Model
|
||||
|
@ -19,21 +20,23 @@ class Payment extends Model
|
|||
* @var string[]
|
||||
*/
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'user_id',
|
||||
'payment_id',
|
||||
'payer_id',
|
||||
'payer',
|
||||
'status',
|
||||
'type',
|
||||
'amount',
|
||||
'price',
|
||||
'id',
|
||||
'user_id',
|
||||
'payment_id',
|
||||
'payer_id',
|
||||
'payer',
|
||||
'status',
|
||||
'type',
|
||||
'amount',
|
||||
'price',
|
||||
'currency_code',
|
||||
];
|
||||
|
||||
public static function boot() {
|
||||
public static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function(Payment $payment) {
|
||||
static::creating(function (Payment $payment) {
|
||||
$client = new Client();
|
||||
|
||||
$payment->{$payment->getKeyName()} = $client->generateId($size = 8);
|
||||
|
@ -43,12 +46,14 @@ class Payment extends Model
|
|||
/**
|
||||
* @return BelongsTo
|
||||
*/
|
||||
public function User(){
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function Price()
|
||||
public function formatCurrency($locale = 'en_US')
|
||||
{
|
||||
return number_format($this->price, 2, '.', '');
|
||||
$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
|
||||
return $formatter->formatCurrency($this->price, $this->currency_code);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,6 +39,10 @@ class PaypalProduct extends Model
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $locale
|
||||
* @return string
|
||||
*/
|
||||
public function formatCurrency($locale = 'en_US')
|
||||
{
|
||||
$formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY);
|
||||
|
|
|
@ -27,6 +27,21 @@ class Product extends Model
|
|||
});
|
||||
}
|
||||
|
||||
public function getHourlyPrice()
|
||||
{
|
||||
return ($this->price / 30) / 24;
|
||||
}
|
||||
|
||||
public function getDailyPrice()
|
||||
{
|
||||
return ($this->price / 30);
|
||||
}
|
||||
|
||||
public function getWeeklyPrice()
|
||||
{
|
||||
return ($this->price / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo
|
||||
*/
|
||||
|
|
|
@ -124,6 +124,7 @@ class Server extends Model
|
|||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return HasOne
|
||||
*/
|
||||
|
|
64
app/Notifications/ConfirmPaymentNotification.php
Normal file
64
app/Notifications/ConfirmPaymentNotification.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ConfirmPaymentNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
private Payment $payment;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Payment $payment)
|
||||
{
|
||||
$this->payment = $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Payment Confirmation')
|
||||
->markdown('mail.payment.confirmed' , ['payment' => $this->payment]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
'title' => "Payment Confirmed!",
|
||||
'content' => "Payment Confirmed!",
|
||||
];
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ use Illuminate\Bus\Queueable;
|
|||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ServerCreationError extends Notification
|
||||
|
||||
{
|
||||
use Queueable;
|
||||
/**
|
||||
|
|
70
app/Notifications/ServersSuspendedNotification.php
Normal file
70
app/Notifications/ServersSuspendedNotification.php
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ServersSuspendedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via($notifiable)
|
||||
{
|
||||
return ['mail' , 'database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return \Illuminate\Notifications\Messages\MailMessage
|
||||
*/
|
||||
public function toMail($notifiable)
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Your servers have been suspended!')
|
||||
->greeting('Your servers have been suspended!')
|
||||
->line("To automatically re-enable your server/s, you need to purchase more credits.")
|
||||
->action('Purchase credits', route('store.index'))
|
||||
->line('If you have any questions please let us know.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
'title' => "Servers suspended!",
|
||||
'content' => "
|
||||
<h5>Your servers have been suspended!</h5>
|
||||
<p>To automatically re-enable your server/s, you need to purchase more credits.</p>
|
||||
<p>If you have any questions please let us know.</p>
|
||||
<p>Regards,<br />" . config('app.name', 'Laravel') . "</p>
|
||||
",
|
||||
];
|
||||
}
|
||||
}
|
|
@ -5,9 +5,10 @@ namespace App\Notifications;
|
|||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WelcomeMessage extends Notification
|
||||
class WelcomeMessage extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
38
database/factories/PaymentFactory.php
Normal file
38
database/factories/PaymentFactory.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Payment;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PaymentFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = Payment::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'payment_id' => Str::random(30),
|
||||
'payer_id' => Str::random(30),
|
||||
'user_id' => User::factory(),
|
||||
'type' => "Credits",
|
||||
'status' => "Completed",
|
||||
'amount' => $this->faker->numberBetween(10, 10000),
|
||||
'price' => $this->faker->numerify('##.##'),
|
||||
'currency_code' => ['EUR', 'USD'][rand(0,1)],
|
||||
'payer' => '{}',
|
||||
];
|
||||
}
|
||||
}
|
33
database/factories/ProductFactory.php
Normal file
33
database/factories/ProductFactory.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ProductFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = Product::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->name,
|
||||
'description' => $this->faker->text(60),
|
||||
'price' => $this->faker->numberBetween(0 , 1000),
|
||||
'memory' => $this->faker->numberBetween(32 , 1024),
|
||||
'disk' => $this->faker->numberBetween(500 , 5000),
|
||||
'databases' => $this->faker->numberBetween(1 , 10)
|
||||
];
|
||||
}
|
||||
}
|
34
database/factories/ServerFactory.php
Normal file
34
database/factories/ServerFactory.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ServerFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = Server::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function definition()
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->name,
|
||||
'description' => $this->faker->text(60),
|
||||
'identifier' => Str::random(30),
|
||||
'pterodactyl_id' => $this->faker->numberBetween(1000000,1000000000),
|
||||
'product_id' => Product::factory()
|
||||
];
|
||||
}
|
||||
}
|
|
@ -25,7 +25,9 @@ class UserFactory extends Factory
|
|||
return [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->unique()->safeEmail,
|
||||
'email_verified_at' => now(),
|
||||
'credits' => $this->faker->numberBetween(0,1500),
|
||||
'last_seen' => $this->faker->dateTimeBetween(now(), '+30 days'),
|
||||
'email_verified_at' => $this->faker->dateTimeBetween('-30 days', now()),
|
||||
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class UpdatePriceToPaymentsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table) {
|
||||
$table->decimal('price')->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table) {
|
||||
$table->string('price')->change()->nullable();;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddCurrencyCodeToPaymentsTable extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table) {
|
||||
$table->string('currency_code' , 3)->default('USD')->after('price');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('payments', function (Blueprint $table) {
|
||||
$table->dropColumn('currency_code');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ConfigurationSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
//initials
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'INITIAL_CREDITS',
|
||||
], [
|
||||
'value' => '250',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'INITIAL_SERVER_LIMIT',
|
||||
], [
|
||||
'value' => '1',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
//verify email event
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'CREDITS_REWARD_AFTER_VERIFY_EMAIL',
|
||||
], [
|
||||
'value' => '250',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'SERVER_LIMIT_REWARD_AFTER_VERIFY_EMAIL',
|
||||
], [
|
||||
'value' => '2',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
//verify discord event
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'CREDITS_REWARD_AFTER_VERIFY_DISCORD',
|
||||
] , [
|
||||
'value' => '375',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'SERVER_LIMIT_REWARD_AFTER_VERIFY_DISCORD',
|
||||
], [
|
||||
'value' => '2',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
|
||||
//other
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'MINIMUM_REQUIRED_CREDITS_TO_MAKE_SERVER',
|
||||
], [
|
||||
'value' => '50',
|
||||
'type' => 'integer',
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Database\Seeders\Seeds\ConfigurationSeeder;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
|
|
21
database/seeders/DevelopmentSeeder.php
Normal file
21
database/seeders/DevelopmentSeeder.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Database\Seeders\Seeds\UserSeeder;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DevelopmentSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Database\Seeders\Seeds\ProductSeeder;
|
||||
use Database\Seeders\Seeds\PaypalProductSeeder;
|
||||
use Database\Seeders\Seeds\ApplicationApiSeeder;
|
||||
use Database\Seeders\Seeds\UsefulLinksSeeder;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ExampleItemsSeeder extends Seeder
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\ApplicationApi;
|
||||
use Illuminate\Database\Seeder;
|
113
database/seeders/Seeds/ConfigurationSeeder.php
Normal file
113
database/seeders/Seeds/ConfigurationSeeder.php
Normal file
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ConfigurationSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
//initials
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'INITIAL_CREDITS',
|
||||
], [
|
||||
'value' => '250',
|
||||
'type' => 'integer',
|
||||
'description' => 'The initial amount of credits the user starts with.'
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'INITIAL_SERVER_LIMIT',
|
||||
], [
|
||||
'value' => '1',
|
||||
'type' => 'integer',
|
||||
'description' => 'The initial server limit the user starts with.'
|
||||
]);
|
||||
|
||||
//verify email event
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'CREDITS_REWARD_AFTER_VERIFY_EMAIL',
|
||||
], [
|
||||
'value' => '250',
|
||||
'type' => 'integer',
|
||||
'description' => 'Increase in credits after the user has verified their email account.'
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'SERVER_LIMIT_REWARD_AFTER_VERIFY_EMAIL',
|
||||
], [
|
||||
'value' => '2',
|
||||
'type' => 'integer',
|
||||
'description' => 'Increase in server limit after the user has verified their email account.'
|
||||
]);
|
||||
|
||||
//verify discord event
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'CREDITS_REWARD_AFTER_VERIFY_DISCORD',
|
||||
], [
|
||||
'value' => '375',
|
||||
'type' => 'integer',
|
||||
'description' => 'Increase in credits after the user has verified their discord account.'
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'SERVER_LIMIT_REWARD_AFTER_VERIFY_DISCORD',
|
||||
], [
|
||||
'value' => '2',
|
||||
'type' => 'integer',
|
||||
'description' => 'Increase in server limit after the user has verified their discord account.'
|
||||
]);
|
||||
|
||||
//other
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'MINIMUM_REQUIRED_CREDITS_TO_MAKE_SERVER',
|
||||
], [
|
||||
'value' => '50',
|
||||
'type' => 'integer',
|
||||
'description' => 'The minimum amount of credits the user would need to make a server.'
|
||||
]);
|
||||
|
||||
//purchasing
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'SERVER_LIMIT_AFTER_IRL_PURCHASE',
|
||||
], [
|
||||
'value' => '10',
|
||||
'type' => 'integer',
|
||||
'description' => 'updates the users server limit to this amount (unless the user already has a higher server limit) after making a purchase with real money, set to 0 to ignore this.',
|
||||
]);
|
||||
|
||||
|
||||
//force email and discord verification
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'FORCE_EMAIL_VERIFICATION',
|
||||
], [
|
||||
'value' => 'false',
|
||||
'type' => 'boolean',
|
||||
'description' => 'Force an user to verify the email adress before creating a server / buying credits.'
|
||||
]);
|
||||
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'FORCE_DISCORD_VERIFICATION',
|
||||
], [
|
||||
'value' => 'false',
|
||||
'type' => 'boolean',
|
||||
'description' => 'Force an user to link an Discord Account before creating a server / buying credits.'
|
||||
]);
|
||||
|
||||
//disable ip check on register
|
||||
Configuration::firstOrCreate([
|
||||
'key' => 'REGISTER_IP_CHECK',
|
||||
], [
|
||||
'value' => 'true',
|
||||
'type' => 'boolean',
|
||||
'description' => 'Prevent users from making multiple accounts using the same IP address'
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\PaypalProduct;
|
||||
use Illuminate\Database\Seeder;
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Seeder;
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\UsefulLink;
|
||||
use Illuminate\Database\Seeder;
|
23
database/seeders/Seeds/UserSeeder.php
Normal file
23
database/seeders/Seeds/UserSeeder.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders\Seeds;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class UserSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function run()
|
||||
{
|
||||
User::factory()
|
||||
->count(10)
|
||||
->has(Server::factory()->count(rand(1,3)) , 'servers')
|
||||
->create();
|
||||
}
|
||||
}
|
BIN
public/images/controlpanel.png
Normal file
BIN
public/images/controlpanel.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 140 KiB |
|
@ -33,7 +33,7 @@
|
|||
@else
|
||||
<div class="callout callout-danger">
|
||||
<h4>No recent activity from cronjobs</h4>
|
||||
<p>Are cronjobs running? <a class="text-primary" target="_blank" href="https://github.com/AVMG20/bitsec-dashboard#crontab-configuration">link</a></p>
|
||||
<p>Are cronjobs running? <a class="text-primary" target="_blank" href="https://github.com/ControlPanel-gg/dashboard/wiki/Installation#crontab-configuration">Check the docs for it here</a></p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th width="600">Description</th>
|
||||
<th>Created at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
|
|
@ -28,48 +28,55 @@
|
|||
<div class="card-header">
|
||||
<h5 class="card-title"><i class="fas fa-money-bill-wave mr-2"></i>Payments</h5>
|
||||
</div>
|
||||
<div class="card-body table-responsive">
|
||||
|
||||
<table class="table table-striped">
|
||||
<div class="card-body table-responsive">
|
||||
<table id="datatable" class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Price</th>
|
||||
<th>Payment_ID</th>
|
||||
<th>Payer_ID</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Price</th>
|
||||
<th>Payment_ID</th>
|
||||
<th>Payer_ID</th>
|
||||
<th>Created at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($payments as $payment)
|
||||
<tr>
|
||||
<td>{{$payment->id}}</td>
|
||||
<td>{{$payment->User->name}}</td>
|
||||
<td>{{$payment->type}}</td>
|
||||
<td><i class="fa fa-coins mr-2"></i>{{$payment->amount}}</td>
|
||||
<td>€{{$payment->Price()}}</td>
|
||||
<td>{{$payment->payment_id}}</td>
|
||||
<td>{{$payment->payer_id}}</td>
|
||||
<td>{{$payment->created_at->diffForHumans()}}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="float-right">
|
||||
{!! $payments->links() !!}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- END CUSTOM CONTENT -->
|
||||
</div>
|
||||
</section>
|
||||
<!-- END CONTENT -->
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
$('#datatable').DataTable({
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
ajax: "{{route('admin.payments.datatable')}}",
|
||||
columns: [
|
||||
{data: 'id' , name : 'payments.id'},
|
||||
{data: 'user', sortable: false},
|
||||
{data: 'type'},
|
||||
{data: 'amount'},
|
||||
{data: 'price'},
|
||||
{data: 'payment_id'},
|
||||
{data: 'payer_id'},
|
||||
{data: 'created_at'},
|
||||
],
|
||||
fnDrawCallback: function( oSettings ) {
|
||||
$('[data-toggle="popover"]').popover();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@endsection
|
||||
|
|
18
resources/views/mail/payment/confirmed.blade.php
Normal file
18
resources/views/mail/payment/confirmed.blade.php
Normal file
|
@ -0,0 +1,18 @@
|
|||
@component('mail::message')
|
||||
# Thank you for your purchase!
|
||||
Your payment has been confirmed; Your credit balance has been updated.
|
||||
|
||||
# Details
|
||||
___
|
||||
### Payment ID: **{{$payment->id}}**
|
||||
### Status: **{{$payment->status}}**
|
||||
### Price: **{{$payment->formatCurrency()}}**
|
||||
### Type: **{{$payment->type}}**
|
||||
### Amount: **{{$payment->amount}}**
|
||||
### Balance: **{{$payment->user->credits}}**
|
||||
### User ID: **{{$payment->user_id}}**
|
||||
|
||||
<br>
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
@endcomponent
|
|
@ -67,8 +67,10 @@
|
|||
|
||||
@foreach($locations as $location)
|
||||
<optgroup label="{{$location->name}}">
|
||||
@foreach($location->nodes as $nodes)
|
||||
<option value="{{$nodes->id}}">{{$nodes->name}}</option>
|
||||
@foreach($location->nodes as $node)
|
||||
@if(!$node->disabled)
|
||||
<option value="{{$node->id}}">{{$node->name}}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</optgroup>
|
||||
@endforeach
|
||||
|
|
|
@ -44,12 +44,12 @@
|
|||
<i class="fas fa-ellipsis-v fa-sm fa-fw text-white-50"></i>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-right shadow animated--fade-in" aria-labelledby="dropdownMenuLink">
|
||||
<a href="{{env('PTERODACTYL_URL' , 'http://localhost')}}/server/{{$server->identifier}}" class="dropdown-item text-info"><i title="manage" class="fas fa-tasks mr-2"></i><span>Manage</span></a>
|
||||
<a href="{{env('PHPMYADMIN_URL' , 'http://localhost')}}" class="dropdown-item text-info"><i title="manage" class="fas fa-database mr-2"></i><span>Database</span></a>
|
||||
<a href="{{env('PTERODACTYL_URL' , 'http://localhost')}}/server/{{$server->identifier}}" target="__blank" class="dropdown-item text-info"><i title="manage" class="fas fa-tasks mr-2"></i><span>Manage</span></a>
|
||||
<a href="{{env('PHPMYADMIN_URL' , 'http://localhost')}}" class="dropdown-item text-info" target="__blank"><i title="manage" class="fas fa-database mr-2"></i><span>Database</span></a>
|
||||
<form method="post" onsubmit="return submitResult();" action="{{route('servers.destroy' , $server->id)}}">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button class="dropdown-item text-danger"><i title="delete" class="fas fa-trash mr-2"></i><span>Delete</span></button>
|
||||
<button class="dropdown-item text-danger"><i title="delete" class="fas fa-trash mr-2"></i><span>Delete server</span></button>
|
||||
</form>
|
||||
<div class="dropdown-divider"></div>
|
||||
<span class="dropdown-item"><i title="Created at" class="fas fa-sync-alt mr-2"></i><span>{{$server->created_at->isoFormat('LL')}}</span></span>
|
||||
|
@ -66,7 +66,7 @@
|
|||
<td>{{$server->product->cpu}} %</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ram</td>
|
||||
<td>RAM</td>
|
||||
<td>{{$server->product->memory}} MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -86,8 +86,8 @@
|
|||
|
||||
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<a href="{{env('PTERODACTYL_URL' , 'http://localhost')}}/server/{{$server->identifier}}" class="btn btn-info mx-3 w-100"><i class="fas fa-tasks mr-2"></i>Manage</a>
|
||||
<a href="{{env('PHPMYADMIN_URL' , 'http://localhost')}}" class="btn btn-info mx-3 w-100"><i class="fas fa-database mr-2"></i>Database</a>
|
||||
<a href="{{env('PTERODACTYL_URL' , 'http://localhost')}}/server/{{$server->identifier}}" target="__blank" class="btn btn-info mx-3 w-100"><i class="fas fa-tasks mr-2"></i>Manage</a>
|
||||
<a href="{{env('PHPMYADMIN_URL' , 'http://localhost')}}" target="__blank" class="btn btn-info mx-3 w-100" ><i class="fas fa-database mr-2"></i>Database</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -59,10 +59,10 @@
|
|||
@else
|
||||
<div class="alert alert-danger alert-dismissible">
|
||||
<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>
|
||||
<h4><i class="icon fa fa-ban"></i> @if($products->count() == 0) The store is temporarily
|
||||
disabled! @else The store is not correctly configured! @endif
|
||||
<h4><i class="icon fa fa-ban"></i> @if($products->count() == 0) There are no store products! @else The store is not correctly configured! @endif
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
|
||||
|
||||
|
|
|
@ -88,6 +88,7 @@ Route::middleware('auth')->group(function () {
|
|||
'store' => 'paypalProduct',
|
||||
]);
|
||||
|
||||
Route::get('payments/datatable', [PaymentController::class, 'datatable'])->name('payments.datatable');
|
||||
Route::get('payments', [PaymentController::class, 'index'])->name('payments.index');
|
||||
|
||||
Route::get('nodes/datatable', [NodeController::class, 'datatable'])->name('nodes.datatable');
|
||||
|
@ -101,6 +102,7 @@ Route::middleware('auth')->group(function () {
|
|||
Route::get('configurations/datatable', [ConfigurationController::class, 'datatable'])->name('configurations.datatable');
|
||||
Route::patch('configurations/updatevalue', [ConfigurationController::class, 'updatevalue'])->name('configurations.updatevalue');
|
||||
Route::resource('configurations', ConfigurationController::class);
|
||||
Route::resource('configurations', ConfigurationController::class);
|
||||
|
||||
Route::patch('settings/update/icons', [SettingsController::class , 'updateIcons'])->name('settings.update.icons');
|
||||
Route::resource('settings', SettingsController::class)->only('index');
|
||||
|
|
Loading…
Add table
Reference in a new issue