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.
This commit is contained in:
baodev0
2026-08-06 17:24:01 +07:00
parent 79f06c2fa2
commit d6d63e5152
29 changed files with 1744 additions and 113 deletions
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Enums\Server;
enum ServerType: string
{
case DOMAIN = 'domain';
case HOSTING = 'hosting';
case BOTH = 'both';
public function label(): string
{
return match ($this) {
self::DOMAIN => 'Domain / DNS',
self::HOSTING => 'Hosting',
self::BOTH => 'Domain & Hosting',
};
}
}
@@ -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,
];
@@ -0,0 +1,146 @@
<?php
namespace App\Http\Controllers\Api\Admin\Hosting;
use App\Http\Controllers\Controller;
use App\Http\Requests\ModuleHosting\CreateHostingAccountRequest;
use App\Http\Requests\ModuleHosting\UpdateHostingAccountRequest;
use App\Http\Resources\Api\Admin\HostingAccountResource;
use App\Repositories\Module\HostingAccount\HostingAccountRepositoryInterface;
use App\Services\Plesk\PleskHostingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class HostingAccountController extends Controller
{
private const RELATIONSHIPS = ['customer', 'server', 'domain'];
public function __construct(
private readonly HostingAccountRepositoryInterface $accounts,
private readonly PleskHostingService $pleskHostingService,
) {}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->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);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\ModuleHosting;
use Illuminate\Foundation\Http\FormRequest;
class CreateHostingAccountRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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',
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\ModuleHosting;
use Illuminate\Foundation\Http\FormRequest;
class UpdateHostingAccountRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* Phase 1 only re-links local metadata — it does not call Plesk, so
* fields that would require re-provisioning (server, domain name,
* plan) are not editable here.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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',
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources\Api\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class HostingAccountResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Mail\Admin;
use App\Mail\Admin\HostingAccountCreatedAdminNotification;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class HostingAccountCreatedAdminNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(public readonly array $data) {}
public function handle(): void
{
$setting = Setting::where('key', 'setting')->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,
]);
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Jobs\Mail\Customer;
use App\Mail\Customer\HostingAccountCreated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class HostingAccountCreatedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public array $data;
public function __construct(array $data)
{
$this->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,
]);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Mail\Admin;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class HostingAccountCreatedAdminNotification extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function envelope(): Envelope
{
return new Envelope(
subject: '[ADMIN] Hosting account mới — ' . ($this->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 [];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Mail\Customer;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class HostingAccountCreated extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
$appName = $this->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);
}
}
+9
View File
@@ -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');
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ModuleHostingAccount extends Model
{
use HasFactory, LogsActivity;
protected $table = 'module_hosting_accounts';
protected $fillable = [
'customer_id',
'service_id',
'dns_manager_server_id',
'dns_manager_domain_id',
'plesk_client_id',
'plesk_subscription_id',
'plesk_guid',
'hosting_plan',
'ftp_username',
'system_username',
'document_root',
'status',
];
public function customer()
{
return $this->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');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ModuleHostingPleskClient extends Model
{
use HasFactory;
protected $table = 'module_hosting_plesk_clients';
protected $fillable = [
'customer_id',
'dns_manager_server_id',
'plesk_client_id',
'login',
'password',
];
public function customer()
{
return $this->belongsTo(Customer::class);
}
public function server()
{
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
}
}
+4
View File
@@ -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
@@ -0,0 +1,48 @@
<?php
namespace App\Repositories\Module\HostingAccount;
use App\Repositories\BaseCachedRepository;
use Illuminate\Support\Facades\Cache;
class HostingAccountCachedRepository extends BaseCachedRepository implements HostingAccountRepositoryInterface
{
public function __construct(
public HostingAccountRepository $hostingAccountRepository
) {
parent::__construct();
}
public function getModel()
{
return $this->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)
);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Repositories\Module\HostingAccount;
use App\Models\ModuleHostingAccount;
use App\Repositories\BaseRepository;
class HostingAccountRepository extends BaseRepository implements HostingAccountRepositoryInterface
{
public function getModel()
{
return ModuleHostingAccount::class;
}
public function filter(
array $data = [],
array $relationships = [],
array $withCount = [],
array $select = ['*'],
bool $returnQuery = false
) {
$query = $this->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();
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Repositories\Module\HostingAccount;
use App\Repositories\BaseRepositoryInterface;
interface HostingAccountRepositoryInterface extends BaseRepositoryInterface
{
public function filter(
array $data = [],
array $relationships = [],
array $withCount = [],
array $select = ['*'],
bool $returnQuery = false
);
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Services\Plesk;
use App\Http\Facade\HelperFacade as Helper;
use App\Models\Customer;
use App\Models\DnsServer;
use App\Models\ModuleHostingPleskClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
/**
* Plesk `/clients` entry point — the customer's own Plesk Panel login,
* separate from the FTP/hosting credential PleskDomainService hands out.
* Plesk never returns a client's password after creation, so it's stored
* (encrypted) in module_hosting_plesk_clients — the only way to ever show
* it again on reuse.
*/
class PleskClientService
{
public function __construct(private PleskService $plesk)
{
}
/**
* @return array{error: bool, data?: array{id: int, login: string, password: ?string}, status?: int, message?: string}
*/
public function findOrCreateCustomer(DnsServer $server, Customer $customer): array
{
$existing = ModuleHostingPleskClient::query()
->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)]
);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace App\Services\Plesk;
use App\Models\DnsServer;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Plesk `/domains` entry point — shared by the DNS module (ServerService)
* and the Hosting module (PleskHostingService). Extracted from
* ServerService so both stop drifting apart on the same Plesk call.
*/
class PleskDomainService
{
private ?string $lastError = null;
public function __construct(private PleskService $plesk)
{
}
/** Message from the most recent failed call — null if the last call succeeded or hasn't run yet. */
public function getLastError(): ?string
{
return $this->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;
}
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Services\Plesk;
use App\Http\Facade\HelperFacade as Helper;
use App\Jobs\Mail\Admin\HostingAccountCreatedAdminNotificationJob;
use App\Jobs\Mail\Customer\HostingAccountCreatedJob;
use App\Models\Customer;
use App\Models\DnsServer;
use App\Models\ModuleHostingAccount;
use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface;
use App\Repositories\Module\HostingAccount\HostingAccountRepositoryInterface;
/**
* Phase 1 orchestration: Create Customer -> 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,
])));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Plesk;
use App\Facades\BaseHelperFacade as BaseHelper;
use App\Models\DnsServer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
class PleskService
{
protected array $entryPoints = [
'clients' => ':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;
}
}
+16 -113
View File
@@ -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