mysql manager: refactoring to services & actions to keep my controller clean

This commit is contained in:
Crivion
2025-10-18 18:06:58 +03:00
parent 2e2745ca34
commit 17af7fed1f
12 changed files with 516 additions and 156 deletions

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Actions\MySQL;
use Illuminate\Support\Facades\DB;
class GetCharsetsAndCollationsAction
{
public function execute(): array
{
return [
'charsets' => $this->getCharsets(),
'collations' => $this->getCollations(),
];
}
private function getCharsets(): array
{
$charsets = DB::select("SHOW CHARACTER SET");
return collect($charsets)->map(function($charset) {
return [
'name' => $charset->Charset,
'description' => $charset->Description,
'default_collation' => $charset->{'Default collation'},
'maxlen' => $charset->Maxlen,
];
})->toArray();
}
private function getCollations(): array
{
$collations = DB::select("SHOW COLLATION");
return collect($collations)->map(function($collation) {
return [
'name' => $collation->Collation,
'charset' => $collation->Charset,
'id' => $collation->Id,
'default' => $collation->Default,
'compiled' => $collation->Compiled,
'sortlen' => $collation->Sortlen,
];
})->toArray();
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Actions\MySQL;
use App\Models\Database;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class GetDatabasesWithStatsAction
{
public function __construct(private User $user) {}
public function execute(): array
{
$databases = Database::where('user_id', $this->user->id)->get();
$items = [];
foreach ($databases as $database) {
$items[] = $this->buildDatabaseItem($database);
}
return $items;
}
private function buildDatabaseItem(Database $database): array
{
$dbName = $database->name;
// Get table count
$tableCount = $this->getTableCount($dbName);
// Get database size
$sizeMb = $this->getDatabaseSize($dbName);
return [
'id' => $database->id,
'name' => $database->name,
'user' => $this->user->username,
'db_user' => $database->db_user,
'tables' => $tableCount,
'sizeMb' => $sizeMb,
'charset' => $database->charset,
'collation' => $database->collation,
];
}
private function getTableCount(string $dbName): int
{
$tables = DB::select(
"SELECT COUNT(*) as cnt FROM information_schema.tables WHERE table_schema = ?",
[$dbName]
);
return (int) ($tables[0]->cnt ?? 0);
}
private function getDatabaseSize(string $dbName): float
{
$sizeRow = DB::selectOne(
"SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE table_schema = ?",
[$dbName]
);
return (float) ($sizeRow->size_mb ?? 0);
}
}

View File

@@ -2,206 +2,81 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Process;
use App\Actions\MySQL\GetCharsetsAndCollationsAction;
use App\Actions\MySQL\GetDatabasesWithStatsAction;
use App\Http\Requests\CreateDatabaseRequest;
use App\Http\Requests\DeleteDatabaseRequest;
use App\Http\Requests\UpdateDatabaseRequest;
use App\Models\Database;
use App\Services\MySQL\CreateDatabaseService;
use App\Services\MySQL\DeleteDatabaseService;
use App\Services\MySQL\UpdateDatabaseService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Inertia\Inertia;
class MysqlController extends Controller
{
public function index(Request $request): \Inertia\Response
{
$user = $request->user();
// Get databases from our model for the current user
$databases = Database::where('user_id', $user->id)->get();
$items = [];
foreach ($databases as $database) {
// Get additional info from MySQL
$dbName = $database->name;
// number of tables
$tables = DB::select("SELECT COUNT(*) as cnt FROM information_schema.tables WHERE table_schema = ?", [$dbName]);
$tableCount = (int) ($tables[0]->cnt ?? 0);
// total size (data + index)
$sizeRow = DB::selectOne("SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables WHERE table_schema = ?", [$dbName]);
$sizeMb = (float) ($sizeRow->size_mb ?? 0);
$items[] = [
'id' => $database->id,
'name' => $database->name,
'user' => $user->username,
'db_user' => $database->db_user,
'tables' => $tableCount,
'sizeMb' => $sizeMb,
'charset' => $database->charset,
'collation' => $database->collation,
];
}
$databases = (new GetDatabasesWithStatsAction($user))->execute();
return Inertia::render('Mysql/Index', [
'databases' => $items,
'databases' => $databases,
]);
}
public function getCharsetsAndCollations()
public function getCharsetsAndCollations(GetCharsetsAndCollationsAction $action): JsonResponse
{
// Get available character sets
$charsets = DB::select("SHOW CHARACTER SET");
$charsetData = collect($charsets)->map(function($charset) {
return [
'name' => $charset->Charset,
'description' => $charset->Description,
'default_collation' => $charset->{'Default collation'},
'maxlen' => $charset->Maxlen,
];
});
// Get available collations
$collations = DB::select("SHOW COLLATION");
$collationData = collect($collations)->map(function($collation) {
return [
'name' => $collation->Collation,
'charset' => $collation->Charset,
'id' => $collation->Id,
'default' => $collation->Default,
'compiled' => $collation->Compiled,
'sortlen' => $collation->Sortlen,
];
});
return response()->json([
'charsets' => $charsetData,
'collations' => $collationData,
]);
return response()->json($action->execute());
}
public function store(Request $request)
public function store(CreateDatabaseRequest $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string'],
'db_user' => ['required', 'string'],
'db_pass' => ['required', 'string'],
'charset' => ['required', 'string'],
'collation' => ['required', 'string'],
]);
$user = $request->user();
$prefix = $user->username . '_';
(new CreateDatabaseService($request->validated(), $user))->handle();
$name = $request->string('name');
$dbUser = $request->string('db_user');
$dbPass = $request->string('db_pass');
$charset = $request->string('charset');
$collation = $request->string('collation');
session()->flash('success', 'Database created successfully!');
if (!str_starts_with($name, $prefix)) {
return back()->withErrors(['name' => 'Database name must start with ' . $prefix]);
}
if (!str_starts_with($dbUser, $prefix)) {
return back()->withErrors(['db_user' => 'Database username must start with ' . $prefix]);
}
// Create database with charset and collation
DB::statement("CREATE DATABASE `$name` CHARACTER SET $charset COLLATE $collation");
// Create user (if not exists) and grant all privileges on the new DB
DB::statement("CREATE USER IF NOT EXISTS `$dbUser`@'localhost' IDENTIFIED BY '$dbPass'");
DB::statement("GRANT ALL PRIVILEGES ON `$name`.* TO `$dbUser`@'localhost'");
DB::statement("FLUSH PRIVILEGES");
// Create database record in our model
Database::create([
'name' => $name,
'db_user' => $dbUser,
'db_password' => $dbPass,
'charset' => $charset,
'collation' => $collation,
'user_id' => $user->id,
]);
return redirect()->route('mysql.index')->with('success', 'Database created successfully.');
return redirect()->route('mysql.index');
}
public function update(Request $request)
public function update(UpdateDatabaseRequest $request): RedirectResponse
{
$request->validate([
'id' => ['required', 'integer'],
'charset' => ['required', 'string'],
'collation' => ['required', 'string'],
'db_password' => ['nullable', 'string'],
]);
$user = $request->user();
$databaseId = $request->integer('id');
$charset = $request->string('charset');
$collation = $request->string('collation');
$newPassword = $request->string('db_password');
// Find the database record
$database = Database::where('id', $databaseId)
->where('user_id', $user->id)
->firstOrFail();
$name = $database->name;
$this->authorize('update', $database);
// Update database charset and collation in MySQL
DB::statement("ALTER DATABASE `$name` CHARACTER SET $charset COLLATE $collation");
(new UpdateDatabaseService($database, $request->validated()))->handle();
// Update the database record
$updateData = [
'charset' => $charset,
'collation' => $collation,
];
session()->flash('success', 'Database updated successfully!');
// Update password if provided
if ($request->filled('db_password')) {
$updateData['db_password'] = $newPassword;
// Update MySQL user password
DB::statement("ALTER USER `{$database->db_user}`@'localhost' IDENTIFIED BY '$newPassword'");
DB::statement("FLUSH PRIVILEGES");
}
$database->update($updateData);
return redirect()->route('mysql.index')->with('success', 'Database charset and collation updated successfully.');
return redirect()->route('mysql.index');
}
public function destroy(Request $request)
public function destroy(DeleteDatabaseRequest $request): RedirectResponse
{
$request->validate([
'id' => ['required', 'integer'],
]);
$user = $request->user();
$databaseId = $request->integer('id');
// Find the database record
$database = Database::where('id', $databaseId)
->where('user_id', $user->id)
->firstOrFail();
$name = $database->name;
$dbUser = $database->db_user;
$this->authorize('delete', $database);
// Drop the MySQL database
DB::statement("DROP DATABASE IF EXISTS `$name`");
(new DeleteDatabaseService($database))->handle();
// Drop the MySQL user
DB::statement("DROP USER IF EXISTS `$dbUser`@'localhost'");
DB::statement("FLUSH PRIVILEGES");
session()->flash('success', 'Database deleted successfully!');
// Delete the database record
$database->delete();
return redirect()->route('mysql.index')->with('success', 'Database deleted successfully.');
return redirect()->route('mysql.index');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Requests;
use App\Models\Database;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateDatabaseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by policy
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$user = $this->user();
$prefix = $user->username . '_';
return [
'name' => [
'required',
'string',
'max:64',
'regex:/^' . preg_quote($prefix) . '[a-zA-Z0-9_]+$/',
'unique:' . Database::class . ',name'
],
'db_user' => [
'required',
'string',
'max:32',
'regex:/^' . preg_quote($prefix) . '[a-zA-Z0-9_]+$/'
],
'db_pass' => ['required', 'string', 'min:8'],
'charset' => ['required', 'string'],
'collation' => ['required', 'string'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
$user = $this->user();
$prefix = $user->username . '_';
return [
'name.regex' => 'Database name must start with ' . $prefix . ' and contain only letters, numbers, and underscores.',
'db_user.regex' => 'Database username must start with ' . $prefix . ' and contain only letters, numbers, and underscores.',
'name.unique' => 'A database with this name already exists.',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DeleteDatabaseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by policy
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'integer'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDatabaseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; // Authorization handled by policy
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'integer'],
'charset' => ['required', 'string'],
'collation' => ['required', 'string'],
'db_password' => ['nullable', 'string', 'min:8'],
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Database;
use Illuminate\Auth\Access\Response;
class DatabasePolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
public function view(User $user, Database $database): Response
{
return ($user->isAdmin() || $user->id === $database->user_id)
? Response::allow()
: Response::deny('You are not authorized to view this database.');
}
public function update(User $user, Database $database): Response
{
return ($user->isAdmin() || $user->id === $database->user_id)
? Response::allow()
: Response::deny('You are not authorized to update this database.');
}
public function delete(User $user, Database $database): Response
{
return ($user->isAdmin() || $user->id === $database->user_id)
? Response::allow()
: Response::deny('You are not authorized to delete this database.');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use App\Models\Database;
use App\Models\Website;
use App\Policies\DatabasePolicy;
use App\Policies\WebsitePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* The model to policy mappings for the application.
*
* @var array<class-string, class-string>
*/
protected $policies = [
Website::class => WebsitePolicy::class,
Database::class => DatabasePolicy::class,
];
/**
* Register any authentication / authorization services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\MySQL;
use App\Models\Database;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\DB;
class CreateDatabaseException extends Exception {}
class CreateDatabaseService
{
public function __construct(private array $validated, private User $user) {}
public function handle(): Database
{
$this->createMySQLDatabase();
$this->createMySQLUser();
return $this->createDatabaseRecord();
}
private function createMySQLDatabase(): void
{
$name = $this->validated['name'];
$charset = $this->validated['charset'];
$collation = $this->validated['collation'];
try {
DB::statement("CREATE DATABASE `$name` CHARACTER SET $charset COLLATE $collation");
} catch (Exception $e) {
throw new CreateDatabaseException('Failed to create MySQL database: ' . $e->getMessage());
}
}
private function createMySQLUser(): void
{
$dbUser = $this->validated['db_user'];
$dbPass = $this->validated['db_pass'];
$name = $this->validated['name'];
try {
DB::statement("CREATE USER IF NOT EXISTS `$dbUser`@'localhost' IDENTIFIED BY '$dbPass'");
DB::statement("GRANT ALL PRIVILEGES ON `$name`.* TO `$dbUser`@'localhost'");
DB::statement("FLUSH PRIVILEGES");
} catch (Exception $e) {
// Rollback database creation if user creation fails
DB::statement("DROP DATABASE IF EXISTS `{$this->validated['name']}`");
throw new CreateDatabaseException('Failed to create MySQL user: ' . $e->getMessage());
}
}
private function createDatabaseRecord(): Database
{
return Database::create([
'name' => $this->validated['name'],
'db_user' => $this->validated['db_user'],
'db_password' => $this->validated['db_pass'],
'charset' => $this->validated['charset'],
'collation' => $this->validated['collation'],
'user_id' => $this->user->id,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services\MySQL;
use App\Models\Database;
use Exception;
use Illuminate\Support\Facades\DB;
class DeleteDatabaseException extends Exception {}
class DeleteDatabaseService
{
public function __construct(private Database $database) {}
public function handle(): void
{
$this->dropMySQLDatabase();
$this->dropMySQLUser();
$this->deleteDatabaseRecord();
}
private function dropMySQLDatabase(): void
{
$name = $this->database->name;
try {
DB::statement("DROP DATABASE IF EXISTS `$name`");
} catch (Exception $e) {
throw new DeleteDatabaseException('Failed to drop MySQL database: ' . $e->getMessage());
}
}
private function dropMySQLUser(): void
{
$dbUser = $this->database->db_user;
try {
DB::statement("DROP USER IF EXISTS `$dbUser`@'localhost'");
DB::statement("FLUSH PRIVILEGES");
} catch (Exception $e) {
throw new DeleteDatabaseException('Failed to drop MySQL user: ' . $e->getMessage());
}
}
private function deleteDatabaseRecord(): void
{
$this->database->delete();
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\MySQL;
use App\Models\Database;
use Exception;
use Illuminate\Support\Facades\DB;
class UpdateDatabaseException extends Exception {}
class UpdateDatabaseService
{
public function __construct(private Database $database, private array $validated) {}
public function handle(): void
{
$this->updateMySQLDatabase();
$this->updateMySQLUserPassword();
$this->updateDatabaseRecord();
}
private function updateMySQLDatabase(): void
{
$name = $this->database->name;
$charset = $this->validated['charset'];
$collation = $this->validated['collation'];
try {
DB::statement("ALTER DATABASE `$name` CHARACTER SET $charset COLLATE $collation");
} catch (Exception $e) {
throw new UpdateDatabaseException('Failed to update MySQL database charset/collation: ' . $e->getMessage());
}
}
private function updateMySQLUserPassword(): void
{
if (!isset($this->validated['db_password']) || empty($this->validated['db_password'])) {
return;
}
$dbUser = $this->database->db_user;
$newPassword = $this->validated['db_password'];
try {
DB::statement("ALTER USER `$dbUser`@'localhost' IDENTIFIED BY '$newPassword'");
DB::statement("FLUSH PRIVILEGES");
} catch (Exception $e) {
throw new UpdateDatabaseException('Failed to update MySQL user password: ' . $e->getMessage());
}
}
private function updateDatabaseRecord(): void
{
$updateData = [
'charset' => $this->validated['charset'],
'collation' => $this->validated['collation'],
];
if (isset($this->validated['db_password']) && !empty($this->validated['db_password'])) {
$updateData['db_password'] = $this->validated['db_password'];
}
$this->database->update($updateData);
}
}

View File

@@ -2,5 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
Lab404\Impersonate\ImpersonateServiceProvider::class,
];