From d6d63e5152767280759abd26e12840f112911e18 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 6 Aug 2026 17:24:01 +0700 Subject: [PATCH] feat: Implement Plesk domain and hosting services - Added PleskDomainService for managing domain operations (create, update, remove, get info). - Introduced PleskHostingService for provisioning hosting accounts and notifying customers. - Created PleskService for handling API requests to Plesk. - Added migration to include 'type' column in dns_manager_servers table. - Created migration for module_hosting_accounts table to track hosting subscriptions. - Created migration for module_hosting_plesk_clients table to manage Plesk client credentials. - Added email templates for notifying customers and admins about hosting account creation. --- app/Enums/Server/ServerType.php | 19 ++ .../Api/Admin/DnsManager/ServerController.php | 4 + .../Hosting/HostingAccountController.php | 146 +++++++++++ .../CreateHostingAccountRequest.php | 34 +++ .../UpdateHostingAccountRequest.php | 34 +++ .../Api/Admin/HostingAccountResource.php | 33 +++ ...tingAccountCreatedAdminNotificationJob.php | 59 +++++ .../Customer/HostingAccountCreatedJob.php | 54 ++++ ...HostingAccountCreatedAdminNotification.php | 38 +++ app/Mail/Customer/HostingAccountCreated.php | 72 ++++++ app/Models/DnsServer.php | 9 + app/Models/ModuleHostingAccount.php | 49 ++++ app/Models/ModuleHostingPleskClient.php | 31 +++ app/Providers/RepositoryProvider.php | 4 + .../HostingAccountCachedRepository.php | 48 ++++ .../HostingAccountRepository.php | 50 ++++ .../HostingAccountRepositoryInterface.php | 16 ++ app/Services/Plesk/PleskClientService.php | 175 +++++++++++++ app/Services/Plesk/PleskDomainService.php | 184 ++++++++++++++ app/Services/Plesk/PleskHostingService.php | 152 ++++++++++++ app/Services/Plesk/PleskService.php | 29 +++ app/Services/ServerService.php | 129 ++-------- ..._add_type_to_dns_manager_servers_table.php | 26 ++ ...1_create_module_hosting_accounts_table.php | 44 ++++ ...ate_module_hosting_plesk_clients_table.php | 36 +++ database/seeders/RolesAndPermissions.php | 6 + .../admin/hosting-account-created.blade.php | 130 ++++++++++ .../hosting-account-created.blade.php | 231 ++++++++++++++++++ routes/api.php | 15 ++ 29 files changed, 1744 insertions(+), 113 deletions(-) create mode 100644 app/Enums/Server/ServerType.php create mode 100644 app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php create mode 100644 app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php create mode 100644 app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php create mode 100644 app/Http/Resources/Api/Admin/HostingAccountResource.php create mode 100644 app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php create mode 100644 app/Jobs/Mail/Customer/HostingAccountCreatedJob.php create mode 100644 app/Mail/Admin/HostingAccountCreatedAdminNotification.php create mode 100644 app/Mail/Customer/HostingAccountCreated.php create mode 100644 app/Models/ModuleHostingAccount.php create mode 100644 app/Models/ModuleHostingPleskClient.php create mode 100644 app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php create mode 100644 app/Repositories/Module/HostingAccount/HostingAccountRepository.php create mode 100644 app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php create mode 100644 app/Services/Plesk/PleskClientService.php create mode 100644 app/Services/Plesk/PleskDomainService.php create mode 100644 app/Services/Plesk/PleskHostingService.php create mode 100644 app/Services/Plesk/PleskService.php create mode 100644 database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php create mode 100644 database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php create mode 100644 database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php create mode 100644 resources/views/emails/admin/hosting-account-created.blade.php create mode 100644 resources/views/emails/customer/hosting-account-created.blade.php diff --git a/app/Enums/Server/ServerType.php b/app/Enums/Server/ServerType.php new file mode 100644 index 0000000..4e1448b --- /dev/null +++ b/app/Enums/Server/ServerType.php @@ -0,0 +1,19 @@ + 'Domain / DNS', + self::HOSTING => 'Hosting', + self::BOTH => 'Domain & Hosting', + }; + } +} diff --git a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php index f12e37d..5e03fcd 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php @@ -74,6 +74,7 @@ class ServerController extends Controller 'password' => 'required|string|max:255', 'api_key' => 'nullable|string', 'status' => 'nullable|string|in:activated,Stopped', + 'type' => 'nullable|string|in:domain,hosting,both', ]); DB::beginTransaction(); @@ -86,6 +87,7 @@ class ServerController extends Controller 'password' => Crypt::encryptString($validated['password']), 'api_key' => $validated['api_key'] ?? null, 'status' => $validated['status'] ?? 'activated', + 'type' => $validated['type'] ?? 'domain', ]); DB::commit(); @@ -116,6 +118,7 @@ class ServerController extends Controller 'password' => 'nullable|string|max:255', 'api_key' => 'nullable|string', 'status' => 'nullable|string|in:activated,Stopped', + 'type' => 'nullable|string|in:domain,hosting,both', ]); DB::beginTransaction(); @@ -182,6 +185,7 @@ class ServerController extends Controller 'username' => $server->username, 'api_key' => $server->api_key, 'status' => $server->status, + 'type' => $server->type, 'created_at' => $server->created_at, 'updated_at' => $server->updated_at, ]; diff --git a/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php b/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php new file mode 100644 index 0000000..6de1aed --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php @@ -0,0 +1,146 @@ +get('per_page', 15), 500); + $filter = $request->get('filter'); + $status = $request->get('status'); + $customerId = $request->get('customer_id'); + + $query = $this->accounts->model + ->with(self::RELATIONSHIPS) + ->newQuery(); + + if ($filter) { + $query->where(function ($sub) use ($filter) { + $sub->where('hosting_plan', 'like', "%{$filter}%") + ->orWhere('ftp_username', 'like', "%{$filter}%") + ->orWhere('system_username', 'like', "%{$filter}%"); + }); + } + + if ($status !== null && $status !== '') { + $query->where('status', $status); + } + + if ($customerId) { + $query->where('customer_id', $customerId); + } + + $paginator = $query->orderByDesc('id')->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => HostingAccountResource::collection($paginator->getCollection()), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $account = $this->accounts->model->with(self::RELATIONSHIPS)->find($id); + + if (!$account) { + return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404); + } + + return response()->json(['error' => false, 'data' => new HostingAccountResource($account)]); + } + + public function store(CreateHostingAccountRequest $request): JsonResponse + { + $result = $this->pleskHostingService->provision($request->validated()); + + if ($result['error']) { + return response()->json([ + 'error' => true, + 'message' => $result['message'] ?? 'Không thể tạo hosting account trên Plesk', + ], 422); + } + + $account = $result['data']->load(self::RELATIONSHIPS); + + return response()->json([ + 'error' => false, + 'data' => new HostingAccountResource($account), + // Not persisted anywhere — this response is the only place it's ever shown. + 'ftp_password' => $result['ftp_password'], + ], 201); + } + + public function update(int $id, UpdateHostingAccountRequest $request): JsonResponse + { + $account = $this->accounts->find($id); + + if (!$account) { + return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404); + } + + $this->accounts->update($id, $request->validated()); + + return $this->show($id); + } + + public function destroy(int $id): JsonResponse + { + $account = $this->accounts->find($id); + + if (!$account) { + return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404); + } + + // Local record only — does not deprovision the subscription on Plesk, + // same as DnsManager\DomainController::destroy leaving the Plesk side alone. + $this->accounts->delete($id); + + return response()->json(['error' => false, 'message' => 'Đã xoá hosting account']); + } + + public function suspend(int $id): JsonResponse + { + return $this->notImplemented(); + } + + public function unsuspend(int $id): JsonResponse + { + return $this->notImplemented(); + } + + public function resetFtpPassword(int $id): JsonResponse + { + return $this->notImplemented(); + } + + private function notImplemented(): JsonResponse + { + return response()->json([ + 'error' => true, + 'message' => 'Not implemented in this phase', + ], 501); + } +} diff --git a/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php b/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php new file mode 100644 index 0000000..b90e0ab --- /dev/null +++ b/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'customer_id' => 'required|integer|exists:customers,id', + 'dns_manager_server_id' => 'required|integer|exists:dns_manager_servers,id', + 'dns_manager_domain_id' => 'nullable|integer|exists:dns_manager_domain,id', + 'service_id' => 'nullable|integer|exists:services,id', + 'domain_name' => 'required|string|max:255', + 'hosting_plan' => 'required|string|max:100', + 'ftp_password' => 'nullable|string|min:8|max:100', + ]; + } +} diff --git a/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php b/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php new file mode 100644 index 0000000..1028d84 --- /dev/null +++ b/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php @@ -0,0 +1,34 @@ +|string> + */ + public function rules(): array + { + return [ + 'dns_manager_domain_id' => 'nullable|integer|exists:dns_manager_domain,id', + 'service_id' => 'nullable|integer|exists:services,id', + 'hosting_plan' => 'sometimes|required|string|max:100', + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/HostingAccountResource.php b/app/Http/Resources/Api/Admin/HostingAccountResource.php new file mode 100644 index 0000000..85a6376 --- /dev/null +++ b/app/Http/Resources/Api/Admin/HostingAccountResource.php @@ -0,0 +1,33 @@ + $this->id, + 'customer_id' => $this->customer_id, + 'customer_name' => $this->whenLoaded('customer', fn () => $this->customer?->fullname), + 'service_id' => $this->service_id, + 'dns_manager_server_id' => $this->dns_manager_server_id, + 'server_name' => $this->whenLoaded('server', fn () => $this->server?->name), + 'dns_manager_domain_id' => $this->dns_manager_domain_id, + 'domain_name' => $this->whenLoaded('domain', fn () => $this->domain?->domain_name), + 'plesk_client_id' => $this->plesk_client_id, + 'plesk_subscription_id' => $this->plesk_subscription_id, + 'plesk_guid' => $this->plesk_guid, + 'hosting_plan' => $this->hosting_plan, + 'ftp_username' => $this->ftp_username, + 'system_username' => $this->system_username, + 'document_root' => $this->document_root, + 'status' => $this->status, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php b/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php new file mode 100644 index 0000000..4ba61a9 --- /dev/null +++ b/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php @@ -0,0 +1,59 @@ +first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + + $adminEmail = $settingValue['admin_email'] ?? null; + + if (empty($adminEmail)) { + Log::warning('HostingAccountCreatedAdminNotificationJob: admin_email not configured in settings.'); + return; + } + + $payload = array_merge($this->data, [ + 'company_name' => $settingValue['company'] ?? 'Portal Admin', + ]); + + try { + Mail::to($adminEmail)->send(new HostingAccountCreatedAdminNotification($payload)); + } catch (\Throwable $e) { + Log::error('HostingAccountCreatedAdminNotificationJob: failed to send email. ' . $e->getMessage(), [ + 'admin_email' => $adminEmail, + 'domain_name' => $this->data['domain_name'] ?? null, + ]); + } + } + + public function failed(\Throwable $e): void + { + Log::error('HostingAccountCreatedAdminNotificationJob: job failed.', [ + 'error' => $e->getMessage(), + 'domain_name' => $this->data['domain_name'] ?? null, + ]); + } +} diff --git a/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php new file mode 100644 index 0000000..bdf0278 --- /dev/null +++ b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php @@ -0,0 +1,54 @@ +data = $data; + } + + public function handle(): void + { + if (empty($this->data['customer_email'])) { + Log::warning('HostingAccountCreatedJob: missing customer_email, skipped.', [ + 'domain_name' => $this->data['domain_name'] ?? null, + ]); + return; + } + + try { + Mail::to($this->data['customer_email']) + ->send(new HostingAccountCreated($this->data)); + } catch (\Throwable $e) { + Log::error('Không thể gửi email thông báo khởi tạo hosting: ' . $e->getMessage(), [ + 'customer_email' => $this->data['customer_email'] ?? null, + 'domain_name' => $this->data['domain_name'] ?? null, + ]); + } + } + + public function failed(\Throwable $e): void + { + Log::error('HostingAccountCreatedJob: job failed.', [ + 'error' => $e->getMessage(), + 'domain_name' => $this->data['domain_name'] ?? null, + ]); + } +} diff --git a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php new file mode 100644 index 0000000..394c56c --- /dev/null +++ b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php @@ -0,0 +1,38 @@ +data['domain_name'] ?? 'N/A'), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.admin.hosting-account-created', + with: $this->data, + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Mail/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php new file mode 100644 index 0000000..0e38afa --- /dev/null +++ b/app/Mail/Customer/HostingAccountCreated.php @@ -0,0 +1,72 @@ +payload()['company_name'] ?? config('app.name', 'The Gioi So'); + + return new Envelope( + subject: 'Hosting của bạn đã sẵn sàng — ' . ($this->data['domain_name'] ?? '') . ' — ' . $appName, + ); + } + + public function content(): Content + { + $data = $this->payload(); + + return new Content( + view: 'emails.customer.hosting-account-created', + with: [ + 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'fullName' => $data['customer_name'] ?? 'Quý khách', + 'domainName' => $data['domain_name'] ?? '', + 'hostingPlan' => $data['hosting_plan'] ?? '', + 'serverHost' => $data['server_host'] ?? '', + 'ftpUsername' => $data['ftp_username'] ?? '', + 'ftpPassword' => $data['ftp_password'] ?? '', + 'pleskLogin' => $data['plesk_login'] ?? '', + 'pleskPassword' => $data['plesk_password'] ?? '', + 'pleskPanelUrl' => $data['plesk_panel_url'] ?? '', + 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function payload(): array + { + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + } catch (\Throwable) { + $settingValue = []; + } + + return array_merge([ + 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'), + 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')), + 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap', + ], $this->data); + } +} diff --git a/app/Models/DnsServer.php b/app/Models/DnsServer.php index a92d94b..38c3358 100644 --- a/app/Models/DnsServer.php +++ b/app/Models/DnsServer.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\Server\ServerType; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,6 +20,7 @@ class DnsServer extends Model 'username', 'api_key', 'status', + 'type', ]; public function getStatus(): string @@ -30,6 +32,13 @@ class DnsServer extends Model }; } + public function servesHosting(): bool + { + $type = $this->attributes['type'] ?? ServerType::DOMAIN->value; + + return in_array($type, [ServerType::HOSTING->value, ServerType::BOTH->value], true); + } + protected function scopeIsActive($query) { return $query->where('status', 'activated'); diff --git a/app/Models/ModuleHostingAccount.php b/app/Models/ModuleHostingAccount.php new file mode 100644 index 0000000..0a8af6e --- /dev/null +++ b/app/Models/ModuleHostingAccount.php @@ -0,0 +1,49 @@ +belongsTo(Customer::class); + } + + public function service() + { + return $this->belongsTo(Service::class); + } + + public function server() + { + return $this->belongsTo(DnsServer::class, 'dns_manager_server_id'); + } + + public function domain() + { + return $this->belongsTo(DnsDomain::class, 'dns_manager_domain_id'); + } +} diff --git a/app/Models/ModuleHostingPleskClient.php b/app/Models/ModuleHostingPleskClient.php new file mode 100644 index 0000000..a996a3a --- /dev/null +++ b/app/Models/ModuleHostingPleskClient.php @@ -0,0 +1,31 @@ +belongsTo(Customer::class); + } + + public function server() + { + return $this->belongsTo(DnsServer::class, 'dns_manager_server_id'); + } +} diff --git a/app/Providers/RepositoryProvider.php b/app/Providers/RepositoryProvider.php index 3f335bc..746700f 100644 --- a/app/Providers/RepositoryProvider.php +++ b/app/Providers/RepositoryProvider.php @@ -82,6 +82,8 @@ use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingR use App\Repositories\Module\HostingServer\HostingServerCachedRepository; use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface; +use App\Repositories\Module\HostingAccount\HostingAccountCachedRepository; +use App\Repositories\Module\HostingAccount\HostingAccountRepositoryInterface; use App\Repositories\Module\IpAddress\IpAddressCachedRepository; use App\Repositories\Module\IpAddress\IpAddressRepositoryInterface; use App\Repositories\Module\Network\NetworkCachedRepository; @@ -198,6 +200,8 @@ class RepositoryProvider extends ServiceProvider $this->app->singleton(ModuleHostingRepositoryInterface::class, ModuleHostingCachedRepository::class); //Hosting Server $this->app->singleton(HostingServerRepositoryInterface::class, HostingServerCachedRepository::class); + //Hosting Account + $this->app->singleton(HostingAccountRepositoryInterface::class, HostingAccountCachedRepository::class); //IpAddress $this->app->singleton(IpAddressRepositoryInterface::class, IpAddressCachedRepository::class); //Network diff --git a/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php b/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php new file mode 100644 index 0000000..f01b6a5 --- /dev/null +++ b/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php @@ -0,0 +1,48 @@ +hostingAccountRepository->getModel(); + } + + public function filter( + array $data = [], + array $relationships = [], + array $withCount = [], + array $select = ['*'], + bool $returnQuery = false + ) { + if ( + $returnQuery || + !empty($data['paginate']) + ) { + return $this->hostingAccountRepository + ->filter($data, $relationships, $withCount, $select, $returnQuery); + } + + $cacheKey = $this->getCacheKey(md5(json_encode([ + 'filter' => compact('data', 'relationships', 'withCount', 'select') + ]))); + + return Cache::remember( + $cacheKey, + $this->cacheTime, + fn() => + $this->hostingAccountRepository + ->filter($data, $relationships, $withCount, $select, $returnQuery) + ); + } +} diff --git a/app/Repositories/Module/HostingAccount/HostingAccountRepository.php b/app/Repositories/Module/HostingAccount/HostingAccountRepository.php new file mode 100644 index 0000000..f5ad900 --- /dev/null +++ b/app/Repositories/Module/HostingAccount/HostingAccountRepository.php @@ -0,0 +1,50 @@ +model + ->select($select) + ->with($relationships); + + if (!empty($data['customer_id'])) { + $query->where('customer_id', $data['customer_id']); + } + + if (!empty($data['status'])) { + $query->where('status', $data['status']); + } + + if (!empty($withCount)) { + $query->withCount($withCount); + } + + if ($returnQuery) { + return $query; + } + + if (!empty($data['paginate'])) { + return $query->paginate( + $data['per_page'] ?? 15 + ); + } + + return $query->get(); + } +} diff --git a/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php b/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php new file mode 100644 index 0000000..5dd4e98 --- /dev/null +++ b/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php @@ -0,0 +1,16 @@ +where('customer_id', $customer->id) + ->where('dns_manager_server_id', $server->id) + ->first(); + + if ($existing) { + return [ + 'error' => false, + 'data' => [ + 'id' => $existing->plesk_client_id, + 'login' => $existing->login, + 'password' => Crypt::decryptString($existing->password), + ], + ]; + } + + $login = $this->generateUsername($customer); + $password = Helper::general_password(); + + $created = $this->createCustomer($server, $customer, $login, $password); + + if (!$created['error']) { + $this->persist($customer, $server, $created['data']['id'], $login, $password); + + return ['error' => false, 'data' => ['id' => $created['data']['id'], 'login' => $login, 'password' => $password]]; + } + + // login already exists on this Plesk box but we have no local record of it (created + // out-of-band, or our own table was reset) — Plesk never gives back the old password, + // so reset it to one we generate now and persist that, instead of losing it again. + if (in_array($created['status'] ?? null, [400, 409], true)) { + $existingRemote = $this->findByLogin($server, $login); + + if ($existingRemote) { + $reset = $this->resetPassword($server, (int) $existingRemote['id'], $password); + + if ($reset) { + $this->persist($customer, $server, (int) $existingRemote['id'], $login, $password); + + return ['error' => false, 'data' => ['id' => (int) $existingRemote['id'], 'login' => $login, 'password' => $password]]; + } + + Log::error('Plesk client found but password reset failed — reusing without a known password', [ + 'plesk_client_id' => $existingRemote['id'], + 'login' => $login, + ]); + + return ['error' => false, 'data' => ['id' => (int) $existingRemote['id'], 'login' => $login, 'password' => null]]; + } + } + + return $created; + } + + private function createCustomer(DnsServer $server, Customer $customer, string $login, string $password): array + { + $param = [ + 'name' => $customer->fullname ?: $login, + 'login' => $login, + 'email' => $customer->email, + 'password' => $password, + 'type' => 'customer', + ]; + + try { + $response = $this->plesk->client($server, 30) + ->post($this->plesk->url($server, 'clients'), $param); + + $data = $response->json(); + + if ($response->successful() && isset($data['id'], $data['guid'])) { + return ['error' => false, 'data' => $data]; + } + + Log::error('Plesk client creation failed', ['status' => $response->status(), 'response' => $data]); + + return [ + 'error' => true, + 'status' => $response->status(), + 'message' => $data['message'] ?? 'Plesk client creation failed', + ]; + } catch (RequestException $e) { + Log::error('Plesk client creation failed', ['error' => $e->getMessage(), 'response' => $e->response?->body()]); + + return ['error' => true, 'status' => $e->response?->status(), 'message' => $e->getMessage()]; + } + } + + private function resetPassword(DnsServer $server, int $pleskClientId, string $password): bool + { + try { + $response = $this->plesk->client($server, 15) + ->put($this->plesk->url($server, 'clients', "/{$pleskClientId}"), ['password' => $password]); + + return $response->successful(); + } catch (RequestException $e) { + Log::error('Plesk client password reset failed', ['error' => $e->getMessage(), 'plesk_client_id' => $pleskClientId]); + + return false; + } + } + + private function findByLogin(DnsServer $server, string $login): ?array + { + try { + $response = $this->plesk->client($server, 15)->get($this->plesk->url($server, 'clients')); + + if (!$response->successful()) { + return null; + } + + foreach ($response->json() ?? [] as $client) { + if (($client['login'] ?? null) === $login) { + return $client; + } + } + } catch (RequestException $e) { + Log::error($e->getMessage()); + } + + return null; + } + + /** + * Plesk `login` is a short username, not the email (email is its own field + * on the same payload). Derived from the email's local part + customer id + * so it's stable and never collides across customers — needed because a + * 409 on create is treated as "this customer already has a client". + */ + private function generateUsername(Customer $customer): string + { + $localPart = strtolower((string) strstr($customer->email, '@', true) ?: 'user'); + $sanitized = trim((string) preg_replace('/[^a-z0-9]+/', '_', $localPart), '_') ?: 'user'; + + return substr($sanitized, 0, 32) . '_' . $customer->id; + } + + private function persist(Customer $customer, DnsServer $server, int $pleskClientId, string $login, string $password): void + { + ModuleHostingPleskClient::query()->updateOrCreate( + ['customer_id' => $customer->id, 'dns_manager_server_id' => $server->id], + ['plesk_client_id' => $pleskClientId, 'login' => $login, 'password' => Crypt::encryptString($password)] + ); + } +} diff --git a/app/Services/Plesk/PleskDomainService.php b/app/Services/Plesk/PleskDomainService.php new file mode 100644 index 0000000..7a0ffda --- /dev/null +++ b/app/Services/Plesk/PleskDomainService.php @@ -0,0 +1,184 @@ +lastError; + } + + public function createDomain(DnsServer $server, array $data = []): array|false + { + $this->lastError = null; + + if (empty($data['domain_name'])) { + $this->lastError = 'Domain name is required'; + return false; + } + + $param = [ + 'name' => $data['domain_name'], + 'description' => '', + 'hosting_type' => 'virtual', + 'hosting_settings' => [ + 'ftp_login' => $data['ftp_login'] ?? str_replace('.', '_', $data['domain_name']), + 'ftp_password' => $data['ftp_password'] ?? Str::random(12), + ], + 'plan' => ['name' => $data['plan'] ?? 'Default Domain'], + ]; + + // Omit ip_addresses/ipv4 unless explicitly given — Plesk assigns its + // own default IP for the domain, and a wrong hardcoded IP here gets + // rejected outright ("IP address 'x.x.x.x' is not available"). + if (!empty($data['ip_addresses'])) { + $param['ip_addresses'] = $data['ip_addresses']; + $param['ipv4'] = $data['ip_addresses']; + } + + if (!empty($data['owner_client_id'])) { + $param['owner_client'] = ['id' => $data['owner_client_id']]; + } + + try { + $response = $this->plesk->client($server, 30) + ->post($this->plesk->url($server, 'domains'), $param); + + $responseData = $response->json(); + + Log::info('Response Plesk API Created', ['data' => $responseData]); + + if ($response->successful() && isset($responseData['id'], $responseData['guid'])) { + return $responseData + ['hosting_settings' => $param['hosting_settings']]; + } + + $this->lastError = $responseData['message'] ?? 'Plesk domain creation failed'; + } catch (RequestException $e) { + $this->lastError = $e->response?->json('message') ?? $e->getMessage(); + Log::error('Plesk domain creation failed', [ + 'error' => $e->getMessage(), + 'response' => $e->response?->body(), + 'data' => $param, + ]); + } + + return false; + } + + public function updateDomain(DnsServer $server, int $pleskDomainId, array $data = []): array|false + { + $this->lastError = null; + + if (empty($pleskDomainId) || empty($data)) { + $this->lastError = 'Invalid request'; + return false; + } + + $param = [ + 'name' => $data['domain_name'], + 'description' => '', + 'hosting_type' => 'virtual', + 'hosting_settings' => [ + 'ftp_login' => $data['ftp_login'] ?? str_replace('.', '_', $data['domain_name']), + 'ftp_password' => $data['ftp_password'] ?? Str::random(12), + ], + 'plan' => ['name' => $data['plan'] ?? 'Default Domain'], + ]; + + if (!empty($data['ip_addresses'])) { + $param['ip_addresses'] = $data['ip_addresses']; + $param['ipv4'] = $data['ip_addresses']; + } + + try { + $response = $this->plesk->client($server, 30) + ->put($this->plesk->url($server, 'domains', "/{$pleskDomainId}"), $param); + + $responseData = $response->json(); + + Log::info('Response Plesk API Updated', ['data' => $responseData]); + + if ($response->successful() && isset($responseData['id'], $responseData['guid'])) { + return $responseData; + } + + $this->lastError = $responseData['message'] ?? 'Plesk domain update failed'; + } catch (RequestException $e) { + $this->lastError = $e->response?->json('message') ?? $e->getMessage(); + Log::error('Plesk domain update failed', [ + 'error' => $e->getMessage(), + 'response' => $e->response?->body(), + 'data' => $param, + ]); + } + + return false; + } + + public function removeDomain(DnsServer $server, int $pleskDomainId): bool + { + try { + $response = $this->plesk->client($server, 30) + ->delete($this->plesk->url($server, 'domains', "/{$pleskDomainId}")); + + if ($response->successful()) { + Log::info('Xoá domain thành công', ['plesk_id' => $pleskDomainId, 'status' => $response->status()]); + return true; + } + + Log::error('Xoá domain thất bại', [ + 'plesk_id' => $pleskDomainId, + 'status' => $response->status(), + 'response' => $response->body(), + ]); + + return false; + } catch (\Exception $e) { + Log::error('Exception khi xoá domain', ['plesk_id' => $pleskDomainId, 'error' => $e->getMessage()]); + return false; + } + } + + public function getDomainInfo(DnsServer $server, string $domain = ''): array|false + { + if (empty($domain)) { + return false; + } + + try { + $response = $this->plesk->client($server, 10) + ->get($this->plesk->url($server, 'domains'), ['name' => $domain]); + + if ($response->successful()) { + foreach ($response->json() ?? [] as $domainInfo) { + if (isset($domainInfo['name']) && $domainInfo['name'] === $domain) { + return $domainInfo; + } + } + } + + return false; + } catch (RequestException $e) { + Log::error($e->getMessage()); + return false; + } + } +} diff --git a/app/Services/Plesk/PleskHostingService.php b/app/Services/Plesk/PleskHostingService.php new file mode 100644 index 0000000..7f52e06 --- /dev/null +++ b/app/Services/Plesk/PleskHostingService.php @@ -0,0 +1,152 @@ + Create Hosting Subscription -> + * Assign Plan -> persist module_hosting_accounts. No database/SSL/mail/git + * deploy — that's later phases. + */ +class PleskHostingService +{ + public function __construct( + private PleskClientService $clients, + private PleskDomainService $domains, + private HostingAccountRepositoryInterface $accounts, + private DnsDomainRepositoryInterface $dnsDomains, + ) { + } + + public function provision(array $data): array + { + $server = DnsServer::find($data['dns_manager_server_id'] ?? null); + + if (!$server) { + return ['error' => true, 'message' => 'Hosting server not found']; + } + + if (!$server->servesHosting()) { + return ['error' => true, 'message' => 'This server does not support hosting']; + } + + $customer = Customer::find($data['customer_id'] ?? null); + + if (!$customer) { + return ['error' => true, 'message' => 'Customer not found']; + } + + $client = $this->clients->findOrCreateCustomer($server, $customer); + + if ($client['error']) { + return $client; + } + + $pleskClientId = $client['data']['id']; + $pleskLogin = $client['data']['login']; + $pleskPassword = $client['data']['password']; + + $ftpLogin = str_replace('.', '_', $data['domain_name']); + // Not persisted anywhere — the only record of it is this response, once. + $ftpPassword = $data['ftp_password'] ?? Helper::general_password(); + + $subscription = $this->domains->createDomain($server, [ + 'domain_name' => $data['domain_name'], + 'ftp_login' => $ftpLogin, + 'ftp_password' => $ftpPassword, + 'plan' => $data['hosting_plan'] ?? null, + 'owner_client_id' => $pleskClientId, + ]); + + if (!$subscription) { + return [ + 'error' => true, + 'message' => $this->domains->getLastError() ?? 'Failed to create Plesk hosting subscription', + ]; + } + + $domainInfo = $this->domains->getDomainInfo($server, $data['domain_name']) ?: []; + + $dnsManagerDomainId = $data['dns_manager_domain_id'] ?? null; + + if (!$dnsManagerDomainId) { + // Plesk's /domains object is the same entity the DNS module tracks — + // creating a hosting subscription there already created the DNS zone + // on Plesk, so mirror it locally instead of leaving it unlinked. + $dnsDomain = $this->dnsDomains->model->firstOrCreate( + [ + 'domain_name' => $data['domain_name'], + 'dns_manager_server_id' => $server->id, + ], + [ + 'plesk_id' => $subscription['id'], + 'is_created' => 1, + ] + ); + + $dnsManagerDomainId = $dnsDomain->id; + } + + $account = $this->accounts->create([ + 'customer_id' => $customer->id, + 'service_id' => $data['service_id'] ?? null, + 'dns_manager_server_id' => $server->id, + 'dns_manager_domain_id' => $dnsManagerDomainId, + 'plesk_client_id' => $pleskClientId, + 'plesk_subscription_id' => $subscription['id'], + 'plesk_guid' => $subscription['guid'], + 'hosting_plan' => $data['hosting_plan'] ?? null, + 'ftp_username' => $subscription['hosting_settings']['ftp_login'] ?? $ftpLogin, + 'system_username' => $subscription['hosting_settings']['ftp_login'] ?? $ftpLogin, + 'document_root' => $domainInfo['www_root'] ?? null, + 'status' => 'active', + ]); + + $this->notify($customer, $server, $account, $data['domain_name'], $ftpPassword, $pleskLogin, $pleskPassword); + + return ['error' => false, 'data' => $account, 'ftp_password' => $ftpPassword]; + } + + private function notify( + Customer $customer, + DnsServer $server, + ModuleHostingAccount $account, + string $domainName, + string $ftpPassword, + string $pleskLogin, + ?string $pleskPassword, + ): void { + $shared = [ + 'customer_name' => $customer->fullname, + 'customer_email' => $customer->email, + 'domain_name' => $domainName, + 'hosting_plan' => $account->hosting_plan, + 'server_name' => $server->name, + 'ftp_username' => $account->ftp_username, + 'ftp_password' => $ftpPassword, + 'plesk_login' => $pleskLogin, + 'plesk_password' => $pleskPassword, + // Plesk's actual login page — not the :8443 REST API entry point. + 'plesk_panel_url' => 'https://' . $server->ip_address . '/login_up.php', + 'created_at' => now()->format('d/m/Y H:i:s'), + ]; + + dispatch(new HostingAccountCreatedJob(array_merge($shared, [ + 'server_host' => $server->ip_address, + ]))); + + dispatch(new HostingAccountCreatedAdminNotificationJob(array_merge($shared, [ + 'server_ip' => $server->ip_address, + 'plesk_client_id' => $account->plesk_client_id, + 'plesk_subscription_id' => $account->plesk_subscription_id, + ]))); + } +} diff --git a/app/Services/Plesk/PleskService.php b/app/Services/Plesk/PleskService.php new file mode 100644 index 0000000..18cd0e0 --- /dev/null +++ b/app/Services/Plesk/PleskService.php @@ -0,0 +1,29 @@ + ':8443/api/v2/clients', + 'domains' => ':8443/api/v2/domains', + ]; + + public function client(DnsServer $server, int $timeout = 30): PendingRequest + { + return Http::withoutVerifying() + ->withBasicAuth($server->username, Crypt::decryptString($server->password)) + ->timeout($timeout); + } + + public function url(DnsServer $server, string $entryPoint, string $suffix = ''): string + { + return BaseHelper::url($server->ip_address) . $this->entryPoints[$entryPoint] . $suffix; + } +} diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index 85607f7..cccadb2 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -5,11 +5,11 @@ namespace App\Services; use App\Facades\BaseHelperFacade as BaseHelper; use App\Models\DnsServer; use App\Repositories\DnsManager\Server\DnsServerRepositoryInterface; +use App\Services\Plesk\PleskDomainService; use Illuminate\Support\Facades\Http; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; class ServerService { @@ -33,7 +33,8 @@ class ServerService ]; public function __construct( - public DnsServerRepositoryInterface $serverRepository + public DnsServerRepositoryInterface $serverRepository, + private PleskDomainService $pleskDomainService, ) { $this->server = $this->getServer(); } @@ -205,32 +206,15 @@ class ServerService public function getDomainInfo(string $domain = ''): array|false { - if (empty($domain)) { + if (empty($domain) || empty($this->server)) { return false; } - try { - $response = Http::withoutVerifying() - ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) - ->timeout(10) - ->get(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'], ['name' => $domain]); + $result = $this->pleskDomainService->getDomainInfo($this->server, $domain); - $this->resetServer(); + $this->resetServer(); - if ($response->successful()) { - $domains = $response->json(); - foreach ($domains as $domainInfo) { - if (isset($domainInfo['name']) && $domainInfo['name'] === $domain) { - return $domainInfo; - } - } - } - - return false; - } catch (RequestException $e) { - Log::error($e->getMessage()); - return false; - } + return $result; } public function createDomain(array $data = []): array|false @@ -243,42 +227,11 @@ class ServerService $this->server = $this->serverRepository->find($data['dns_manager_server_id']); } - $param = [ - 'name' => $data['domain_name'], - 'description' => '', - 'hosting_type' => 'virtual', - 'hosting_settings' => [ - 'ftp_login' => str_replace('.', '_', $data['domain_name']), - 'ftp_password' => Str::random(12), - ], - 'ip_addresses' => ['103.42.58.124'], - 'ipv4' => ['103.42.58.124'], - 'plan' => ['name' => 'Default Domain'], - ]; + $result = $this->pleskDomainService->createDomain($this->server, $data); - try { - $response = Http::withoutVerifying() - ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) - ->timeout(30) - ->post(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'], $param); + $this->resetServer(); - $this->resetServer(); - $responseData = $response->json(); - - Log::info('Response Plesk API Created', ['data' => $responseData]); - - if ($response->successful() && isset($responseData['id'], $responseData['guid'])) { - return $responseData; - } - } catch (RequestException $e) { - Log::error('Plesk domain creation failed', [ - 'error' => $e->getMessage(), - 'response' => $e->response?->body(), - 'data' => $param, - ]); - } - - return false; + return $result; } public function updateDomain(int $pleskDomainId, array $data = []): array|false @@ -291,42 +244,11 @@ class ServerService $this->server = $this->serverRepository->find($data['dns_manager_server_id']); } - $param = [ - 'name' => $data['domain_name'], - 'description' => '', - 'hosting_type' => 'virtual', - 'hosting_settings' => [ - 'ftp_login' => str_replace('.', '_', $data['domain_name']), - 'ftp_password' => Str::random(12), - ], - 'ip_addresses' => ['103.42.58.124'], - 'ipv4' => ['103.42.58.124'], - 'plan' => ['name' => 'Default Domain'], - ]; + $result = $this->pleskDomainService->updateDomain($this->server, $pleskDomainId, $data); - try { - $response = Http::withoutVerifying() - ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) - ->timeout(30) - ->put(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'] . "/{$pleskDomainId}", $param); + $this->resetServer(); - $this->resetServer(); - $responseData = $response->json(); - - Log::info('Response Plesk API Updated', ['data' => $responseData]); - - if ($response->successful() && isset($responseData['id'], $responseData['guid'])) { - return $responseData; - } - } catch (RequestException $e) { - Log::error('Plesk domain update failed', [ - 'error' => $e->getMessage(), - 'response' => $e->response?->body(), - 'data' => $param, - ]); - } - - return false; + return $result; } public function removeDomain(int $pleskDomainId): bool @@ -335,30 +257,11 @@ class ServerService return false; } - try { - $response = Http::withoutVerifying() - ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password)) - ->timeout(30) - ->delete(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'] . "/{$pleskDomainId}"); + $result = $this->pleskDomainService->removeDomain($this->server, $pleskDomainId); - $this->resetServer(); + $this->resetServer(); - if ($response->successful()) { - Log::info('Xoá domain thành công', ['plesk_id' => $pleskDomainId, 'status' => $response->status()]); - return true; - } - - Log::error('Xoá domain thất bại', [ - 'plesk_id' => $pleskDomainId, - 'status' => $response->status(), - 'response' => $response->body(), - ]); - - return false; - } catch (\Exception $e) { - Log::error('Exception khi xoá domain', ['plesk_id' => $pleskDomainId, 'error' => $e->getMessage()]); - return false; - } + return $result; } public function addServerById(int $serverId): bool diff --git a/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php b/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php new file mode 100644 index 0000000..4386bbd --- /dev/null +++ b/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php @@ -0,0 +1,26 @@ +string('type', 20)->default('domain')->after('status'); + }); + + // every existing server already serves DNS today + DB::table('dns_manager_servers')->update(['type' => 'domain']); + } + + public function down(): void + { + Schema::table('dns_manager_servers', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +}; diff --git a/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php b/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php new file mode 100644 index 0000000..2e30e39 --- /dev/null +++ b/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php @@ -0,0 +1,44 @@ +id(); + + $table->unsignedBigInteger('customer_id')->index(); + $table->unsignedBigInteger('service_id')->nullable()->index(); + $table->unsignedBigInteger('dns_manager_server_id')->index(); + $table->unsignedBigInteger('dns_manager_domain_id')->nullable()->index(); + + $table->unsignedInteger('plesk_client_id')->nullable(); + $table->unsignedInteger('plesk_subscription_id')->nullable()->index(); + $table->string('plesk_guid', 64)->nullable(); + + $table->string('hosting_plan', 100)->nullable(); + $table->string('ftp_username', 100)->nullable(); + $table->string('system_username', 100)->nullable(); + $table->string('document_root', 255)->nullable(); + + // pending -> active -> suspended -> terminated (mirrors services.status) + $table->string('status', 20)->default('pending')->index(); + + $table->timestamps(); + + $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade'); + $table->foreign('service_id')->references('id')->on('services')->onDelete('set null'); + $table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('restrict'); + $table->foreign('dns_manager_domain_id')->references('id')->on('dns_manager_domain')->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::dropIfExists('module_hosting_accounts'); + } +}; diff --git a/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php b/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php new file mode 100644 index 0000000..971a8cd --- /dev/null +++ b/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php @@ -0,0 +1,36 @@ +id(); + + $table->unsignedBigInteger('customer_id')->index(); + $table->unsignedBigInteger('dns_manager_server_id')->index(); + $table->unsignedInteger('plesk_client_id')->index(); + + $table->string('login', 100); + // Plesk panel login password (Crypt::encryptString) — separate credential + // from the FTP/hosting password, which is never stored anywhere. + $table->text('password'); + + $table->timestamps(); + + $table->unique(['dns_manager_server_id', 'customer_id'], 'hosting_plesk_clients_server_customer_unique'); + + $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade'); + $table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('module_hosting_plesk_clients'); + } +}; diff --git a/database/seeders/RolesAndPermissions.php b/database/seeders/RolesAndPermissions.php index 56c2c85..1acef0e 100644 --- a/database/seeders/RolesAndPermissions.php +++ b/database/seeders/RolesAndPermissions.php @@ -139,6 +139,12 @@ class RolesAndPermissions extends Seeder 'domain.destroy', 'domain.manager', + // Hosting + 'hosting.view', + 'hosting.create', + 'hosting.edit', + 'hosting.destroy', + // Vai trò & Phân quyền 'role.view', 'role.create', diff --git a/resources/views/emails/admin/hosting-account-created.blade.php b/resources/views/emails/admin/hosting-account-created.blade.php new file mode 100644 index 0000000..7e43751 --- /dev/null +++ b/resources/views/emails/admin/hosting-account-created.blade.php @@ -0,0 +1,130 @@ + + + + + + Thông báo hosting mới + + + +
+
+
+
🖥️
+

Hosting account mới được khởi tạo

+

{{ $domain_name ?? 'N/A' }} — {{ $created_at ?? now()->format('d/m/Y H:i:s') }}

+
+ +
+ + + + + + + + + + +
Tên khách hàng{{ $customer_name ?? 'N/A' }}
Email{{ $customer_email ?? 'N/A' }}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Tên miền{{ $domain_name ?? 'N/A' }}
Gói hosting{{ $hosting_plan ?? 'N/A' }}
Server{{ $server_name ?? 'N/A' }} ({{ $server_ip ?? 'N/A' }})
Plesk Client ID{{ $plesk_client_id ?? 'N/A' }}
Plesk Subscription ID{{ $plesk_subscription_id ?? 'N/A' }}
Trạng thái✅ Active
+ + +
+
+ 👤 FTP Username + {{ $ftp_username ?? 'N/A' }} +
+ @if(!empty($ftp_password)) +
+ 🔑 FTP Password + {{ $ftp_password }} +
+ @endif +
+ + +
+
+ 🌐 Panel URL + {{ $plesk_panel_url ?? 'N/A' }} +
+
+ 👤 Login + {{ $plesk_login ?? 'N/A' }} +
+ @if(!empty($plesk_password)) +
+ 🔑 Password + {{ $plesk_password }} +
+ @endif +
+
+ Đây là tài khoản Plesk riêng của khách hàng (đã gửi kèm cho khách), khác với tài khoản quản trị + của server dùng chung — tài khoản quản trị server không được đính kèm trong email này vì lý do + bảo mật. +
+
+ + +
+
+ + diff --git a/resources/views/emails/customer/hosting-account-created.blade.php b/resources/views/emails/customer/hosting-account-created.blade.php new file mode 100644 index 0000000..dffcb21 --- /dev/null +++ b/resources/views/emails/customer/hosting-account-created.blade.php @@ -0,0 +1,231 @@ +@php + $appName = $appName ?? config('app.name', 'Thế Giới Số'); + $fullName = $fullName ?? 'Quý khách'; + $domainName = $domainName ?? ''; + $hostingPlan = $hostingPlan ?? ''; + $serverHost = $serverHost ?? ''; + $ftpUsername = $ftpUsername ?? ''; + $ftpPassword = $ftpPassword ?? ''; + $pleskLogin = $pleskLogin ?? ''; + $pleskPassword = $pleskPassword ?? ''; + $pleskPanelUrl = $pleskPanelUrl ?? ''; + $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap'; + $supportEmail = $supportEmail ?? 'support@tgs.com.vn'; + $supportPhone = $supportPhone ?? '1900. 6119'; +@endphp + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + +
+
🎉
+

+ Hosting của bạn đã sẵn sàng +

+
+ Gói hosting cho tên miền {{ $domainName }} đã được khởi tạo thành công +
+
+ + + + + + +
+
 
+
Xin chào {{ $fullName }},
+
 
+
+ Dịch vụ hosting của bạn tại {{ $appName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập: +
+
 
+ + + + + + +
+
Tên miền: {{ $domainName }}
+
Gói hosting: {{ $hostingPlan }}
+ @if($serverHost) +
Máy chủ / FTP Host: {{ $serverHost }}
+ @endif + @if($ftpUsername) +
Tài khoản FTP: {{ $ftpUsername }}
+ @endif + @if($ftpPassword) +
Mật khẩu FTP: {{ $ftpPassword }}
+ @endif +
Trạng thái: Đang hoạt động ✓
+
+
 
+ @if($ftpPassword) +
+ ⚠️ Vì lý do bảo mật, mật khẩu FTP chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn. +
+
 
+ @endif + @if($pleskLogin) +
Tài khoản đăng nhập Plesk Panel (để tự quản lý domain, DNS, database, mail...):
+
 
+ + + + + + +
+
Địa chỉ Panel: {{ $pleskPanelUrl }}
+
Tên đăng nhập: {{ $pleskLogin }}
+ @if($pleskPassword) +
Mật khẩu: {{ $pleskPassword }}
+ @endif +
+
 
+ @endif +
+ Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình. +
+
 
+ +
 
+
+ Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}. +
+
 
+
+ Email này được gửi tự động. Vui lòng không trả lời email này. +
+
 
+
+
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + +
 
+
 
+ + + + + + + + + + + + + + +
  + Premium HOSTING +   + Cloud SERVER +   + EMAIL DOANH NGHIỆP +   + Thiết Kế WEBSITE +  
+
 
+ + + + + + +
 
+
+
+ + + + + + + + + + + + +
 
+

+ CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
+ Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh
+ Tel: (028) 7309. 7379 - Tổng đài: {{ $supportPhone }}
+ Email: {{ $supportEmail }}
+ + www.TGS.com.vn & + www.thegioiso.CLOUD & + www.thegioiSERVER.com & + www.CloudEmail.vn + +

+

+ + + + 🥇Thế Giới Số🥇- Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự 🚀 + + + +

+
 
+
+
diff --git a/routes/api.php b/routes/api.php index bd0d1c2..ff26e12 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,6 +25,7 @@ use App\Http\Controllers\Api\Admin\VCenter\VCenterController; use App\Http\Controllers\Api\Admin\DnsManager\ServerController as DnsServerController; use App\Http\Controllers\Api\Admin\DnsManager\DomainController as DnsDomainController; use App\Http\Controllers\Api\Admin\DnsManager\RecordController as DnsRecordController; +use App\Http\Controllers\Api\Admin\Hosting\HostingAccountController; use App\Http\Controllers\Api\Admin\OsManager\OsGroupController; use App\Http\Controllers\Api\Admin\OsManager\OsController; use App\Http\Controllers\Api\Admin\IpPool\IpPoolController; @@ -420,6 +421,20 @@ Route::prefix('v1') Route::put('{id}', 'update')->middleware('perm:domain.manager'); }); + // Hosting Manager (module: hosting) + Route::prefix('hosting-accounts') + ->controller(HostingAccountController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:hosting.view'); + Route::get('{id}', 'show')->middleware('perm:hosting.view'); + Route::post('/', 'store')->middleware('perm:hosting.create'); + Route::put('{id}', 'update')->middleware('perm:hosting.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy'); + Route::post('{id}/suspend', 'suspend')->middleware('perm:hosting.edit'); + Route::post('{id}/unsuspend', 'unsuspend')->middleware('perm:hosting.edit'); + Route::post('{id}/reset-ftp-password', 'resetFtpPassword')->middleware('perm:hosting.edit'); + }); + // InfraManagement (jumphost, vcenter config) Route::prefix('infra-management') ->controller(InfraManagementController::class)