feat: Implement vCenter management APIs, console ticket retrieval, password encryption, and background synchronization.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Facade\SignRSA;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EncryptVCenterPasswords extends Command
|
||||
{
|
||||
protected $signature = 'vcenter:encrypt-passwords';
|
||||
protected $description = 'Re-encrypt vCenter passwords from Laravel encrypted cast to SignRSA format';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$rows = DB::table('module_vcenter')->get(['id', 'password']);
|
||||
$fixed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if (!$row->password) continue;
|
||||
|
||||
// Try to decrypt as Laravel encrypted cast (from previous migration)
|
||||
$plainPassword = null;
|
||||
try {
|
||||
$plainPassword = Crypt::decryptString($row->password);
|
||||
$this->info("vCenter #{$row->id}: detected Laravel encrypted password, will re-encrypt with SignRSA");
|
||||
} catch (\Exception) {
|
||||
// Not Laravel encrypted — check if it's already SignRSA or plain text
|
||||
$decrypted = SignRSA::DecryptFunc($row->password);
|
||||
if ($decrypted) {
|
||||
$this->info("vCenter #{$row->id}: already SignRSA encrypted, skipping");
|
||||
continue;
|
||||
}
|
||||
// Plain text password
|
||||
$plainPassword = $row->password;
|
||||
$this->info("vCenter #{$row->id}: detected plain text password, will encrypt with SignRSA");
|
||||
}
|
||||
|
||||
if ($plainPassword) {
|
||||
$encrypted = SignRSA::EncryptFunc($plainPassword);
|
||||
DB::table('module_vcenter')
|
||||
->where('id', $row->id)
|
||||
->update(['password' => $encrypted]);
|
||||
$fixed++;
|
||||
$this->info("vCenter #{$row->id}: re-encrypted successfully");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. Fixed {$fixed} password(s).");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,166 @@
|
||||
namespace App\Http\Controllers\Api\Admin\VCenter;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VirtualMachine\Sync\SyncAllPackage;
|
||||
use App\Models\ModuleDatacenter;
|
||||
use App\Models\ModuleDatastore;
|
||||
use App\Models\ModuleFolder;
|
||||
use App\Models\ModuleHost;
|
||||
use App\Models\ModuleNetwork;
|
||||
use App\Models\vCenter;
|
||||
use App\Models\VirtualMachines;
|
||||
use App\Http\Services\vCenter as ServiceVCenter;
|
||||
use App\Http\Facade\SignRSA;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VCenterController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'host' => 'required|string|max:255',
|
||||
'user' => 'required|string|max:255',
|
||||
'password' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$vc = vCenter::create([
|
||||
'host' => $request->input('host'),
|
||||
'user' => $request->input('user'),
|
||||
'password' => SignRSA::EncryptFunc($request->input('password')),
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $vc], 201);
|
||||
}
|
||||
|
||||
public function consoleTicket(Request $request, ServiceVCenter $serviceVCenter): JsonResponse
|
||||
{
|
||||
$vmDbId = $request->integer('vm_id');
|
||||
if (!$vmDbId) {
|
||||
return response()->json(['error' => true, 'message' => 'Thiếu vm_id'], 422);
|
||||
}
|
||||
|
||||
$vm = VirtualMachines::with('datacenter.vCenter')->find($vmDbId);
|
||||
if (!$vm) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy VM'], 404);
|
||||
}
|
||||
|
||||
$vcenter = $vm->datacenter?->vCenter ?? null;
|
||||
if (!$vcenter) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy thông tin vCenter'], 404);
|
||||
}
|
||||
|
||||
if (!$serviceVCenter->loginServer($vcenter)) {
|
||||
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập vào vCenter'], 500);
|
||||
}
|
||||
|
||||
$ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id);
|
||||
|
||||
if ($ticket === 'Login_Again') {
|
||||
if ($serviceVCenter->loginServer($vcenter)) {
|
||||
$ticket = $serviceVCenter->GetConsoleTickets($vm->vm_id);
|
||||
} else {
|
||||
return response()->json(['error' => true, 'message' => 'Không thể đăng nhập lại vào vCenter'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($ticket)) {
|
||||
return response()->json(['error' => true, 'message' => "Lấy ticket thất bại: $ticket"], 500);
|
||||
}
|
||||
|
||||
if ($ticket['error']) {
|
||||
return response()->json(['error' => true, 'message' => $ticket['data']], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => $ticket['data'],
|
||||
'host' => $vcenter->host,
|
||||
'vmware-api-session-id' => $serviceVCenter->token,
|
||||
])->cookie('vmware-api-session-id', $serviceVCenter->token, 60, '/', null, false, false);
|
||||
}
|
||||
|
||||
public function sync(): JsonResponse
|
||||
{
|
||||
dispatch(new SyncAllPackage())->delay(now()->addSeconds(5));
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Sync job dispatched, running in background.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateNode(Request $request, string $type, int $id): JsonResponse
|
||||
{
|
||||
$model = $this->resolveModel($type, $id);
|
||||
|
||||
$rules = match ($type) {
|
||||
'vcenter' => ['host' => 'sometimes|string|max:255', 'user' => 'sometimes|string|max:255', 'password' => 'sometimes|string|max:255', 'status' => 'sometimes|integer|in:0,1'],
|
||||
'datacenter' => ['name' => 'sometimes|string|max:255', 'datacenter_id' => 'sometimes|string|max:255'],
|
||||
'host' => ['name' => 'sometimes|string|max:255', 'host_id' => 'sometimes|string|max:255', 'template' => 'sometimes|nullable|string|max:500', 'console' => 'sometimes|nullable|string|max:500', 'iso_path' => 'sometimes|nullable|string|max:500'],
|
||||
'datastore' => ['name' => 'sometimes|string|max:255', 'datastore' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255', 'space' => 'sometimes|nullable|integer', 'status' => 'sometimes|integer|in:0,1'],
|
||||
'network' => ['name' => 'sometimes|string|max:255', 'network' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'],
|
||||
'folder' => ['name' => 'sometimes|string|max:255', 'folder' => 'sometimes|string|max:255', 'type' => 'sometimes|nullable|string|max:255'],
|
||||
'virtual_machine' => ['name' => 'sometimes|string|max:255', 'vm_id' => 'sometimes|string|max:255'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$request->validate($rules);
|
||||
|
||||
$data = $request->only(array_keys($rules));
|
||||
|
||||
if ($type === 'vcenter' && isset($data['password'])) {
|
||||
$data['password'] = SignRSA::EncryptFunc($data['password']);
|
||||
}
|
||||
|
||||
$model->update($data);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $model]);
|
||||
}
|
||||
|
||||
public function destroyNode(string $type, int $id): JsonResponse
|
||||
{
|
||||
$model = $this->resolveModel($type, $id);
|
||||
|
||||
$childCheck = match ($type) {
|
||||
'vcenter' => $model->datacenters()->exists(),
|
||||
'datacenter' => ModuleHost::where('module_datacenter_id', $id)->exists(),
|
||||
'host' => $model->datastore()->exists() || $model->network()->exists(),
|
||||
default => false,
|
||||
};
|
||||
|
||||
if ($childCheck) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Không thể xoá vì vẫn còn dữ liệu liên kết bên dưới.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$model->delete();
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'Đã xoá thành công.']);
|
||||
}
|
||||
|
||||
public function tree(Request $request): JsonResponse
|
||||
{
|
||||
$vcenters = vCenter::with(['datacenters'])->get();
|
||||
$allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id');
|
||||
$vcenters = vCenter::with(['datacenters'])->get();
|
||||
$allHosts = ModuleHost::with(['datastore', 'network'])->get()->groupBy('module_datacenter_id');
|
||||
$allFolders = ModuleFolder::all()->groupBy('module_datacenter_id');
|
||||
// VMs with a host assignment — grouped by host DB id
|
||||
$allVMs = VirtualMachines::whereNotNull('host_id')->get()->groupBy('host_id');
|
||||
// VMs without a host assignment (synced before host_id was tracked) — grouped by datacenter DB id
|
||||
$orphanVMs = VirtualMachines::whereNull('host_id')->get()->groupBy('datacenter_id');
|
||||
|
||||
$tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders) {
|
||||
$tree = $vcenters->map(function ($vc) use ($allHosts, $allFolders, $allVMs, $orphanVMs) {
|
||||
$dcGroups = ($vc->datacenters ?? collect())->groupBy('datacenter_id');
|
||||
$datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders) {
|
||||
$datacenters = $dcGroups->map(function ($dcGroup) use ($allHosts, $allFolders, $allVMs, $orphanVMs) {
|
||||
$dc = $dcGroup->first();
|
||||
$dcIds = $dcGroup->pluck('id');
|
||||
$uniqueHosts = $dcIds->flatMap(fn($id) => $allHosts->get($id) ?? collect())
|
||||
->unique('host_id')->values();
|
||||
|
||||
// Networks — deduplicated at datacenter level
|
||||
$dcNetworks = $uniqueHosts
|
||||
->flatMap(fn($host) => $host->network ?? collect())
|
||||
->unique('network')
|
||||
@@ -32,10 +170,36 @@ class VCenterController extends Controller
|
||||
'id' => 'network-' . $net->id,
|
||||
'name' => $net->name,
|
||||
'type' => 'network',
|
||||
'meta' => ['db_id' => $net->id, 'network_id' => $net->network, 'net_type' => $net->type],
|
||||
'meta' => [
|
||||
'db_id' => $net->id,
|
||||
'network' => $net->network,
|
||||
'net_type' => $net->type,
|
||||
'module_host_id' => $net->module_host_id,
|
||||
],
|
||||
]);
|
||||
|
||||
$dcHosts = $uniqueHosts->map(function ($host) {
|
||||
// Hosts — VMs placed under their host, then Datastores
|
||||
$dcHosts = $uniqueHosts->map(function ($host) use ($allVMs) {
|
||||
$hostVMs = ($allVMs->get($host->id) ?? collect())
|
||||
->map(fn($vm) => [
|
||||
'id' => 'virtual_machine-' . $vm->id,
|
||||
'name' => $vm->name,
|
||||
'type' => 'virtual_machine',
|
||||
'meta' => [
|
||||
'db_id' => $vm->id,
|
||||
'uuid' => $vm->uuid,
|
||||
'vm_id' => $vm->vm_id,
|
||||
'power_state' => $vm->power_state,
|
||||
'folder' => $vm->folder,
|
||||
'cpu' => $vm->cpu,
|
||||
'ram_mb' => $vm->ram_mb,
|
||||
'is_template' => $vm->is_template,
|
||||
'is_system' => $vm->is_system,
|
||||
'last_synced_at' => $vm->last_synced_at,
|
||||
],
|
||||
'children' => [],
|
||||
]);
|
||||
|
||||
$datastores = ($host->datastore ?? collect())
|
||||
->unique('datastore')
|
||||
->map(fn($ds) => [
|
||||
@@ -43,11 +207,13 @@ class VCenterController extends Controller
|
||||
'name' => $ds->name,
|
||||
'type' => 'datastore',
|
||||
'meta' => [
|
||||
'db_id' => $ds->id,
|
||||
'datastore_id' => $ds->datastore,
|
||||
'store_type' => $ds->type,
|
||||
'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null,
|
||||
'status' => $ds->status,
|
||||
'db_id' => $ds->id,
|
||||
'datastore' => $ds->datastore,
|
||||
'store_type' => $ds->type,
|
||||
'space' => $ds->space,
|
||||
'space_gb' => $ds->space ? round($ds->space / 1024, 1) : null,
|
||||
'status' => $ds->status,
|
||||
'module_host_id' => $ds->module_host_id,
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -55,11 +221,21 @@ class VCenterController extends Controller
|
||||
'id' => 'host-' . $host->id,
|
||||
'name' => $host->name,
|
||||
'type' => 'host',
|
||||
'meta' => ['db_id' => $host->id, 'host_id' => $host->host_id, 'console' => $host->console],
|
||||
'children' => array_values($datastores->toArray()),
|
||||
'meta' => [
|
||||
'db_id' => $host->id,
|
||||
'host_id' => $host->host_id,
|
||||
'template' => $host->template,
|
||||
'console' => $host->console,
|
||||
'iso_path' => $host->iso_path,
|
||||
'module_datacenter_id' => $host->module_datacenter_id,
|
||||
'vm_count' => $hostVMs->count(),
|
||||
],
|
||||
// VMs first, then Datastores — correct vSphere order
|
||||
'children' => array_values($hostVMs->concat($datastores)->toArray()),
|
||||
];
|
||||
});
|
||||
|
||||
// Folders — datacenter level
|
||||
$dcFolders = $dcIds
|
||||
->flatMap(fn($id) => $allFolders->get($id) ?? collect())
|
||||
->unique('folder')
|
||||
@@ -67,15 +243,48 @@ class VCenterController extends Controller
|
||||
'id' => 'folder-' . $f->id,
|
||||
'name' => $f->name,
|
||||
'type' => 'folder',
|
||||
'meta' => ['db_id' => $f->id, 'folder_id' => $f->folder, 'folder_type' => $f->type],
|
||||
'meta' => [
|
||||
'db_id' => $f->id,
|
||||
'folder' => $f->folder,
|
||||
'folder_type' => $f->type,
|
||||
'module_datacenter_id' => $f->module_datacenter_id,
|
||||
],
|
||||
]);
|
||||
|
||||
// Orphan VMs — no host_id, grouped directly under datacenter
|
||||
$dcOrphanVMs = $dcIds
|
||||
->flatMap(fn($id) => $orphanVMs->get($id) ?? collect())
|
||||
->unique('id')
|
||||
->map(fn($vm) => [
|
||||
'id' => 'virtual_machine-' . $vm->id,
|
||||
'name' => $vm->name,
|
||||
'type' => 'virtual_machine',
|
||||
'meta' => [
|
||||
'db_id' => $vm->id,
|
||||
'uuid' => $vm->uuid,
|
||||
'vm_id' => $vm->vm_id,
|
||||
'power_state' => $vm->power_state,
|
||||
'folder' => $vm->folder,
|
||||
'cpu' => $vm->cpu,
|
||||
'ram_mb' => $vm->ram_mb,
|
||||
'is_template' => $vm->is_template,
|
||||
'is_system' => $vm->is_system,
|
||||
'last_synced_at' => $vm->last_synced_at,
|
||||
],
|
||||
'children' => [],
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => 'datacenter-' . $dc->id,
|
||||
'name' => $dc->name,
|
||||
'type' => 'datacenter',
|
||||
'meta' => ['db_id' => $dc->id, 'datacenter_id' => $dc->datacenter_id],
|
||||
'children' => array_values($dcFolders->concat($dcNetworks)->concat($dcHosts)->toArray()),
|
||||
'meta' => [
|
||||
'db_id' => $dc->id,
|
||||
'datacenter_id' => $dc->datacenter_id,
|
||||
'vcenter_id' => $dc->vcenter_id,
|
||||
],
|
||||
// vSphere order: Hosts → Networks → Folders → Orphan VMs
|
||||
'children' => array_values($dcHosts->concat($dcNetworks)->concat($dcFolders)->concat($dcOrphanVMs)->toArray()),
|
||||
];
|
||||
})->values();
|
||||
|
||||
@@ -90,4 +299,18 @@ class VCenterController extends Controller
|
||||
|
||||
return response()->json(['error' => false, 'data' => $tree->values()]);
|
||||
}
|
||||
|
||||
private function resolveModel(string $type, int $id)
|
||||
{
|
||||
return match ($type) {
|
||||
'vcenter' => vCenter::findOrFail($id),
|
||||
'datacenter' => ModuleDatacenter::findOrFail($id),
|
||||
'host' => ModuleHost::findOrFail($id),
|
||||
'datastore' => ModuleDatastore::findOrFail($id),
|
||||
'network' => ModuleNetwork::findOrFail($id),
|
||||
'folder' => ModuleFolder::findOrFail($id),
|
||||
'virtual_machine' => VirtualMachines::findOrFail($id),
|
||||
default => abort(404, 'Unknown node type'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class ProductResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'product_type' => $this->product_type,
|
||||
'category' => $this->whenLoaded('category', fn() => $this->category?->name),
|
||||
'cpu' => $this->cpu,
|
||||
|
||||
@@ -148,7 +148,6 @@ class vCenter
|
||||
->get("https://$this->apiUri/rest/vcenter/folder", [
|
||||
'filter.datacenters.1' => $datacenter,
|
||||
'filter.type' => "VIRTUAL_MACHINE",
|
||||
'filter.names' => "vm"
|
||||
])->json();
|
||||
if (!isset($response['type'])) {
|
||||
return $response['value'];
|
||||
|
||||
@@ -91,14 +91,10 @@ class SyncDatacenter implements ShouldQueue
|
||||
continue; // skip malformed items
|
||||
}
|
||||
|
||||
$exists = ModuleDatacenter::where('datacenter_id', $_item['datacenter'])->first();
|
||||
if (!$exists) {
|
||||
ModuleDatacenter::create([
|
||||
'name' => $_item['name'],
|
||||
'datacenter_id' => $_item['datacenter'],
|
||||
'vcenter_id' => $item->id
|
||||
]);
|
||||
}
|
||||
ModuleDatacenter::updateOrCreate(
|
||||
['datacenter_id' => $_item['datacenter']],
|
||||
['name' => $_item['name'], 'vcenter_id' => $item->id]
|
||||
);
|
||||
}
|
||||
}
|
||||
// Notify success via Telegram
|
||||
|
||||
@@ -63,22 +63,26 @@ class SyncFolder implements ShouldQueue
|
||||
vCenter::where('id', $item->id)->update(['token' => $token]);
|
||||
$this->vCenter->token = $token;
|
||||
}
|
||||
}
|
||||
$folder = $this->vCenter->getAllFolder($_item['datacenter_id']);
|
||||
|
||||
if ($folder) {
|
||||
foreach ($folder as $_subItem) {
|
||||
if (!ModuleFolder::where('folder', $_subItem['folder'])->first()) {
|
||||
ModuleFolder::create([
|
||||
'name' => $_subItem['name'],
|
||||
'folder' => $_subItem['folder'],
|
||||
'type' => $_subItem['type'],
|
||||
'module_datacenter_id' => $_item['id']
|
||||
]);
|
||||
}
|
||||
}
|
||||
$folder = $this->vCenter->getAllFolder($_item['datacenter_id']);
|
||||
}
|
||||
|
||||
if (!is_array($folder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$syncedFolderIds = [];
|
||||
foreach ($folder as $_subItem) {
|
||||
$record = ModuleFolder::updateOrCreate(
|
||||
['folder' => $_subItem['folder'], 'module_datacenter_id' => $_item['id']],
|
||||
['name' => $_subItem['name'], 'type' => $_subItem['type']]
|
||||
);
|
||||
$syncedFolderIds[] = $record->id;
|
||||
}
|
||||
|
||||
// Remove stale folders no longer in vCenter
|
||||
ModuleFolder::where('module_datacenter_id', $_item['id'])
|
||||
->whereNotIn('id', $syncedFolderIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -77,17 +77,15 @@ class SyncHost implements ShouldQueue
|
||||
// Use TemplateService
|
||||
$template = $templateService->getAllTemplate();
|
||||
|
||||
if ($host) {
|
||||
foreach ($host as $_subItem) {
|
||||
if (!ModuleHost::where('host_id', $_subItem['host'])->exists()) {
|
||||
ModuleHost::create([
|
||||
'name' => $_subItem['name'],
|
||||
'host_id' => $_subItem['host'],
|
||||
'module_datacenter_id' => $_item['id'],
|
||||
'template' => $template[0] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
foreach ($host as $_subItem) {
|
||||
ModuleHost::updateOrCreate(
|
||||
['host_id' => $_subItem['host']],
|
||||
[
|
||||
'name' => $_subItem['name'],
|
||||
'module_datacenter_id' => $_item['id'],
|
||||
'template' => $template[0] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class vCenter extends Model
|
||||
'token',
|
||||
'status'
|
||||
];
|
||||
|
||||
protected $casts = [];
|
||||
public function datacenters()
|
||||
{
|
||||
return $this->hasMany(\App\Models\ModuleDatacenter::class, 'vcenter_id');
|
||||
|
||||
@@ -12,8 +12,7 @@ return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('invoice', function (Blueprint $table) {
|
||||
//
|
||||
$table->enum('reminder_step', [InvoiceStep::STEP_1->value, InvoiceStep::STEP_2->value, InvoiceStep::STEP_3->value, InvoiceStep::STEP_4->value, InvoiceStep::STEP_5->value])->default(InvoiceStep::STEP_1->value)->after('auto_renew');
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ Route::prefix('v1')
|
||||
// vCenter
|
||||
Route::prefix('vcenter')->group(function () {
|
||||
Route::get('tree', [VCenterController::class, 'tree']);
|
||||
Route::get('console-ticket', [VCenterController::class, 'consoleTicket']);
|
||||
Route::post('/', [VCenterController::class, 'store']);
|
||||
Route::post('sync', [VCenterController::class, 'sync']);
|
||||
Route::put('node/{type}/{id}', [VCenterController::class, 'updateNode']);
|
||||
Route::delete('node/{type}/{id}', [VCenterController::class, 'destroyNode']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user