Add installer functionality for WordPress with FTP and database configuration

- Create SQL migration for prompt templates used in article and image generation.
- Add migration to change publish interval from days to hours in the sites table.
- Implement InstallerController to handle installation requests and validation.
- Develop FtpService for FTP connections and file uploads.
- Create InstallerService to manage the WordPress installation process, including downloading, extracting, and configuring WordPress.
- Add index view for the installer with form inputs for FTP, database, and WordPress admin settings.
- Implement progress tracking for the installation process with AJAX polling.
This commit is contained in:
2026-02-16 21:55:24 +01:00
parent 884ee9cc88
commit b653cea252
37 changed files with 2899 additions and 204 deletions

140
src/Services/FtpService.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Helpers\Logger;
class FtpService
{
private $connection = null;
private string $host;
private string $user;
private string $pass;
private int $port;
private bool $ssl;
private int $uploadedFiles = 0;
private int $totalFiles = 0;
/** @var callable|null */
private $progressCallback = null;
public function __construct(string $host, string $user, string $pass, int $port = 21, bool $ssl = false)
{
$this->host = $host;
$this->user = $user;
$this->pass = $pass;
$this->port = $port;
$this->ssl = $ssl;
}
public function setProgressCallback(callable $callback): void
{
$this->progressCallback = $callback;
}
public function connect(): void
{
if ($this->ssl) {
$this->connection = @ftp_ssl_connect($this->host, $this->port, 30);
} else {
$this->connection = @ftp_connect($this->host, $this->port, 30);
}
if (!$this->connection) {
throw new \RuntimeException("Nie można połączyć z FTP: {$this->host}:{$this->port}");
}
if (!@ftp_login($this->connection, $this->user, $this->pass)) {
throw new \RuntimeException("Logowanie FTP nieudane dla użytkownika: {$this->user}");
}
ftp_pasv($this->connection, true);
Logger::info("FTP connected to {$this->host}", 'installer');
}
public function uploadDirectory(string $localDir, string $remoteDir): void
{
// Count total files before starting (only on first/top-level call)
if ($this->totalFiles === 0) {
$this->totalFiles = $this->countFiles($localDir);
$this->uploadedFiles = 0;
}
$this->ensureDirectory($remoteDir);
$items = scandir($localDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$localPath = $localDir . '/' . $item;
$remotePath = $remoteDir . '/' . $item;
if (is_dir($localPath)) {
$this->uploadDirectory($localPath, $remotePath);
} else {
$this->uploadFile($localPath, $remotePath);
$this->uploadedFiles++;
// Report progress every 50 files to avoid excessive writes
if ($this->progressCallback && $this->uploadedFiles % 50 === 0) {
($this->progressCallback)($this->uploadedFiles, $this->totalFiles);
}
}
}
}
public function uploadFile(string $localPath, string $remotePath): void
{
if (!@ftp_put($this->connection, $remotePath, $localPath, FTP_BINARY)) {
throw new \RuntimeException("FTP upload failed: {$remotePath}");
}
}
public function ensureDirectory(string $path): void
{
$parts = explode('/', trim($path, '/'));
$current = '';
foreach ($parts as $part) {
$current .= '/' . $part;
@ftp_mkdir($this->connection, $current);
}
}
public function disconnect(): void
{
if ($this->connection) {
@ftp_close($this->connection);
$this->connection = null;
Logger::info("FTP disconnected", 'installer');
}
}
public function __destruct()
{
$this->disconnect();
}
private function countFiles(string $dir): int
{
$count = 0;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
if (is_dir($path)) {
$count += $this->countFiles($path);
} else {
$count++;
}
}
return $count;
}
}

View File

@@ -9,6 +9,8 @@ use App\Helpers\Logger;
class ImageService
{
public const DEFAULT_FREEPIK_PROMPT_TEMPLATE = 'Professional blog header image about {topic_name}: {article_title}, high quality, photorealistic';
private Client $client;
public function __construct()
@@ -31,6 +33,11 @@ class ImageService
private function generateFreepik(string $articleTitle, string $topicName): ?array
{
$apiKey = Config::getDbSetting('freepik_api_key', Config::get('FREEPIK_API_KEY'));
$promptTemplate = Config::getDbSetting('image_generation_prompt', self::DEFAULT_FREEPIK_PROMPT_TEMPLATE);
if (!is_string($promptTemplate) || trim($promptTemplate) === '') {
$promptTemplate = self::DEFAULT_FREEPIK_PROMPT_TEMPLATE;
}
if (empty($apiKey)) {
Logger::warning('Freepik API key not configured, falling back to Pexels', 'image');
@@ -38,7 +45,10 @@ class ImageService
}
try {
$prompt = "Professional blog header image about {$topicName}: {$articleTitle}, high quality, photorealistic";
$prompt = strtr($promptTemplate, [
'{topic_name}' => $topicName,
'{article_title}' => $articleTitle,
]);
$response = $this->client->post('https://api.freepik.com/v1/ai/text-to-image', [
'headers' => [

View File

@@ -0,0 +1,442 @@
<?php
declare(strict_types=1);
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\GuzzleException;
use App\Helpers\Logger;
use App\Models\Site;
class InstallerService
{
private Client $http;
private string $tempDir = '';
private string $progressId = '';
public function __construct()
{
$this->http = new Client(['timeout' => 60, 'verify' => false]);
}
private static function progressFilePath(string $id): string
{
return sys_get_temp_dir() . '/backpro_progress_' . $id . '.json';
}
private function updateProgress(int $percent, string $message, string $status = 'in_progress'): void
{
if (empty($this->progressId)) {
return;
}
$data = [
'percent' => min($percent, 100),
'message' => $message,
'status' => $status,
'time' => date('H:i:s'),
];
@file_put_contents(self::progressFilePath($this->progressId), json_encode($data), LOCK_EX);
}
public static function getProgress(string $id): ?array
{
$file = self::progressFilePath($id);
if (!file_exists($file)) {
return null;
}
$data = @file_get_contents($file);
return $data ? json_decode($data, true) : null;
}
public static function cleanupProgress(string $id): void
{
@unlink(self::progressFilePath($id));
}
/**
* @return array{success: bool, message: string, site_id: int|null}
*/
public function install(array $config, string $progressId = ''): array
{
set_time_limit(600);
ini_set('memory_limit', '256M');
$this->progressId = $progressId;
Logger::info("Starting WordPress installation for {$config['site_url']}", 'installer');
$this->updateProgress(5, 'Pobieranie WordPress...');
try {
// Step 1: Download WordPress
$zipPath = $this->downloadWordPress($config['language']);
$this->updateProgress(15, 'Rozpakowywanie archiwum...');
// Step 2: Extract ZIP
$wpSourceDir = $this->extractZip($zipPath);
$this->updateProgress(25, 'Generowanie wp-config.php...');
// Step 3: Generate wp-config.php
$this->generateWpConfig($wpSourceDir, $config);
$this->updateProgress(30, 'Łączenie z serwerem FTP...');
// Step 4: Upload via FTP
$this->uploadViaFtp($wpSourceDir, $config);
$this->updateProgress(85, 'Uruchamianie instalacji WordPress...');
// Step 5: Trigger WordPress installation
$this->triggerInstallation($config);
$this->updateProgress(92, 'Tworzenie Application Password...');
// Step 6: Create Application Password
$appPassword = $this->createApplicationPassword($config);
$this->updateProgress(97, 'Rejestracja strony w BackPRO...');
// Step 7: Register site in BackPRO (with all credentials)
$siteId = Site::create([
'name' => $config['site_title'],
'url' => $config['site_url'],
'api_user' => $config['admin_user'],
'api_token' => $appPassword,
'publish_interval_hours' => 24,
'is_active' => 1,
'is_multisite' => 0,
'ftp_host' => $config['ftp_host'],
'ftp_port' => $config['ftp_port'],
'ftp_user' => $config['ftp_user'],
'ftp_pass' => $config['ftp_pass'],
'ftp_path' => $config['ftp_path'],
'db_host' => $config['db_host'],
'db_name' => $config['db_name'],
'db_user' => $config['db_user'],
'db_pass' => $config['db_pass'],
'db_prefix' => $config['db_prefix'],
'wp_admin_user' => $config['admin_user'],
'wp_admin_pass' => $config['admin_pass'],
'wp_admin_email' => $config['admin_email'],
]);
Logger::info("WordPress installed and site registered (ID: {$siteId})", 'installer');
$this->updateProgress(100, 'Instalacja zakończona pomyślnie!', 'completed');
$this->cleanup();
return [
'success' => true,
'message' => "WordPress zainstalowany pomyślnie! Strona \"{$config['site_title']}\" została dodana do BackPRO.",
'site_id' => $siteId,
];
} catch (\Throwable $e) {
Logger::error("Installation failed: " . $e->getMessage(), 'installer');
$this->updateProgress(0, 'Błąd: ' . $e->getMessage(), 'failed');
$this->cleanup();
return [
'success' => false,
'message' => 'Błąd instalacji: ' . $e->getMessage(),
'site_id' => null,
];
}
}
private function downloadWordPress(string $language): string
{
Logger::info("Downloading WordPress ({$language})", 'installer');
$url = ($language === 'pl_PL')
? 'https://pl.wordpress.org/latest-pl_PL.zip'
: 'https://wordpress.org/latest.zip';
$this->tempDir = sys_get_temp_dir() . '/backpro_wp_' . uniqid();
if (!mkdir($this->tempDir, 0755, true)) {
throw new \RuntimeException('Nie można utworzyć katalogu tymczasowego');
}
$zipPath = $this->tempDir . '/wordpress.zip';
$this->http->get($url, [
'sink' => $zipPath,
'timeout' => 120,
'headers' => [
'User-Agent' => 'BackPRO/1.0 (WordPress Installer)',
],
]);
if (!file_exists($zipPath) || filesize($zipPath) < 1000000) {
throw new \RuntimeException('Pobieranie WordPress nie powiodło się');
}
Logger::info("Downloaded WordPress (" . round(filesize($zipPath) / 1048576, 1) . " MB)", 'installer');
return $zipPath;
}
private function extractZip(string $zipPath): string
{
Logger::info("Extracting WordPress ZIP", 'installer');
$zip = new \ZipArchive();
$result = $zip->open($zipPath);
if ($result !== true) {
throw new \RuntimeException("Nie można otworzyć pliku ZIP (kod błędu: {$result})");
}
$extractDir = $this->tempDir . '/extracted';
$zip->extractTo($extractDir);
$zip->close();
$wpDir = $extractDir . '/wordpress';
if (!is_dir($wpDir)) {
throw new \RuntimeException('Rozpakowany archiwum nie zawiera katalogu wordpress/');
}
@unlink($zipPath);
Logger::info("Extracted to {$wpDir}", 'installer');
return $wpDir;
}
private function generateWpConfig(string $wpDir, array $config): void
{
Logger::info("Generating wp-config.php", 'installer');
$salts = $this->fetchSalts();
$dbHost = addcslashes($config['db_host'], "'\\");
$dbName = addcslashes($config['db_name'], "'\\");
$dbUser = addcslashes($config['db_user'], "'\\");
$dbPass = addcslashes($config['db_pass'], "'\\");
$dbPrefix = addcslashes($config['db_prefix'], "'\\");
$wpConfig = <<<PHP
<?php
/**
* WordPress configuration - generated by BackPRO Installer
*/
// Database settings
define( 'DB_NAME', '{$dbName}' );
define( 'DB_USER', '{$dbUser}' );
define( 'DB_PASSWORD', '{$dbPass}' );
define( 'DB_HOST', '{$dbHost}' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
// Authentication unique keys and salts
{$salts}
// Table prefix
\$table_prefix = '{$dbPrefix}';
// Debug mode
define( 'WP_DEBUG', false );
// Absolute path to the WordPress directory
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
// Load WordPress
require_once ABSPATH . 'wp-settings.php';
PHP;
$configPath = $wpDir . '/wp-config.php';
if (file_put_contents($configPath, $wpConfig) === false) {
throw new \RuntimeException('Nie można zapisać wp-config.php');
}
Logger::info("wp-config.php generated", 'installer');
}
private function fetchSalts(): string
{
try {
$response = $this->http->get('https://api.wordpress.org/secret-key/1.1/salt/', ['timeout' => 10]);
$salts = $response->getBody()->getContents();
if (str_contains($salts, 'define(')) {
return $salts;
}
} catch (GuzzleException $e) {
Logger::warning("Cannot fetch salts from API, generating locally", 'installer');
}
$keys = [
'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
];
$lines = [];
foreach ($keys as $key) {
$salt = bin2hex(random_bytes(32));
$lines[] = "define( '{$key}', '{$salt}' );";
}
return implode("\n", $lines);
}
private function uploadViaFtp(string $wpDir, array $config): void
{
Logger::info("Starting FTP upload to {$config['ftp_host']}:{$config['ftp_path']}", 'installer');
$ftp = new FtpService(
$config['ftp_host'],
$config['ftp_user'],
$config['ftp_pass'],
$config['ftp_port'],
$config['ftp_ssl']
);
// FTP progress callback: maps file count to 35-85% range
$ftp->setProgressCallback(function (int $uploaded, int $total) {
$ftpPercent = ($total > 0) ? ($uploaded / $total) : 0;
$percent = 35 + (int) ($ftpPercent * 50); // 35% to 85%
$this->updateProgress($percent, "Wgrywanie plików FTP... ({$uploaded}/{$total})");
});
try {
$ftp->connect();
$this->updateProgress(35, 'Wgrywanie plików na serwer FTP...');
$ftp->uploadDirectory($wpDir, $config['ftp_path']);
Logger::info("FTP upload completed", 'installer');
} finally {
$ftp->disconnect();
}
}
private function triggerInstallation(array $config): void
{
Logger::info("Triggering WordPress installation at {$config['site_url']}", 'installer');
$installUrl = $config['site_url'] . '/wp-admin/install.php?step=2';
try {
$response = $this->http->post($installUrl, [
'form_params' => [
'weblog_title' => $config['site_title'],
'user_name' => $config['admin_user'],
'admin_password' => $config['admin_pass'],
'admin_password2' => $config['admin_pass'],
'admin_email' => $config['admin_email'],
'blog_public' => 0,
],
'timeout' => 60,
'allow_redirects' => true,
]);
$body = $response->getBody()->getContents();
if (
str_contains($body, 'wp-login.php') ||
str_contains($body, 'Success') ||
str_contains($body, 'Udane') ||
str_contains($body, 'install-success')
) {
Logger::info("WordPress installation triggered successfully", 'installer');
return;
}
Logger::warning("WordPress install response unclear, HTTP " . $response->getStatusCode(), 'installer');
} catch (GuzzleException $e) {
throw new \RuntimeException("Instalacja WordPress nie powiodła się: " . $e->getMessage());
}
}
private function createApplicationPassword(array $config): string
{
Logger::info("Creating Application Password via WP cookie auth", 'installer');
sleep(3);
$jar = new CookieJar();
$siteUrl = $config['site_url'];
try {
// Step 1: Login via wp-login.php to get auth cookies
Logger::info("Logging in to WordPress admin", 'installer');
$this->http->post($siteUrl . '/wp-login.php', [
'form_params' => [
'log' => $config['admin_user'],
'pwd' => $config['admin_pass'],
'wp-submit' => 'Log In',
'redirect_to' => $siteUrl . '/wp-admin/',
'testcookie' => '1',
],
'cookies' => $jar,
'allow_redirects' => true,
'timeout' => 30,
'headers' => ['User-Agent' => 'BackPRO/1.0'],
]);
// Step 2: Get REST API nonce via admin-ajax
Logger::info("Fetching REST API nonce", 'installer');
$nonceResponse = $this->http->get($siteUrl . '/wp-admin/admin-ajax.php?action=rest-nonce', [
'cookies' => $jar,
'timeout' => 15,
'headers' => ['User-Agent' => 'BackPRO/1.0'],
]);
$nonce = trim($nonceResponse->getBody()->getContents());
if (empty($nonce) || $nonce === '0' || $nonce === '-1') {
throw new \RuntimeException('Nie udało się pobrać nonce REST API (logowanie nieudane?)');
}
Logger::info("Got REST nonce, creating Application Password", 'installer');
// Step 3: Create Application Password with cookie auth + nonce
$apiUrl = $siteUrl . '/?rest_route=/wp/v2/users/me/application-passwords';
$response = $this->http->post($apiUrl, [
'cookies' => $jar,
'headers' => [
'X-WP-Nonce' => $nonce,
'User-Agent' => 'BackPRO/1.0',
],
'json' => [
'name' => 'BackPRO ' . date('Y-m-d H:i'),
],
'timeout' => 30,
]);
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['password'])) {
throw new \RuntimeException('Odpowiedź API nie zawiera hasła aplikacji');
}
Logger::info("Application Password created successfully", 'installer');
return $data['password'];
} catch (GuzzleException $e) {
throw new \RuntimeException("Nie można utworzyć Application Password: " . $e->getMessage());
}
}
private function cleanup(): void
{
if (!empty($this->tempDir) && is_dir($this->tempDir)) {
$this->deleteDirectory($this->tempDir);
Logger::info("Cleaned up temp directory", 'installer');
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

View File

@@ -9,6 +9,8 @@ use App\Helpers\Logger;
class OpenAIService
{
public const DEFAULT_ARTICLE_PROMPT_TEMPLATE = 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów <html>, <body>, <head>). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}';
private Client $client;
public function __construct()
@@ -25,6 +27,11 @@ class OpenAIService
$model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o'));
$minWords = Config::getDbSetting('article_min_words', '800');
$maxWords = Config::getDbSetting('article_max_words', '1200');
$systemPromptTemplate = Config::getDbSetting('article_generation_prompt', self::DEFAULT_ARTICLE_PROMPT_TEMPLATE);
if (!is_string($systemPromptTemplate) || trim($systemPromptTemplate) === '') {
$systemPromptTemplate = self::DEFAULT_ARTICLE_PROMPT_TEMPLATE;
}
if (empty($apiKey)) {
Logger::error('OpenAI API key not configured', 'openai');
@@ -35,11 +42,10 @@ class OpenAIService
? implode("\n- ", $existingTitles)
: '(brak - to pierwszy artykuł z tego tematu)';
$systemPrompt = "Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, "
. "optymalizowane pod SEO. Artykuł powinien mieć {$minWords}-{$maxWords} słów, "
. "zawierać nagłówki H2 i H3, być angażujący i merytoryczny. "
. "Formatuj treść w HTML (bez tagów <html>, <body>, <head>). "
. "Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {\"title\": \"tytuł artykułu\", \"content\": \"treść HTML artykułu\"}";
$systemPrompt = strtr($systemPromptTemplate, [
'{min_words}' => (string) $minWords,
'{max_words}' => (string) $maxWords,
]);
$userPrompt = "Napisz artykuł na temat: {$topicName}\n";
if (!empty($topicDescription)) {

View File

@@ -49,6 +49,24 @@ class WordPressService
}
}
public function createCategory(array $site, string $name, int $parent = 0): ?array
{
try {
$response = $this->client->post($site['url'] . '/wp-json/wp/v2/categories', [
'auth' => [$site['api_user'], $site['api_token']],
'json' => [
'name' => $name,
'parent' => $parent,
],
]);
return json_decode($response->getBody()->getContents(), true);
} catch (GuzzleException $e) {
Logger::error("WP createCategory failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function uploadMedia(array $site, string $imageData, string $filename): ?int
{
try {
@@ -104,6 +122,51 @@ class WordPressService
}
}
public function getPostFeaturedMedia(array $site, int $wpPostId): ?int
{
try {
$response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['_fields' => 'featured_media'],
]);
$data = json_decode($response->getBody()->getContents(), true);
$mediaId = $data['featured_media'] ?? 0;
return $mediaId > 0 ? $mediaId : null;
} catch (GuzzleException $e) {
Logger::error("WP getPostFeaturedMedia failed: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function updatePostFeaturedMedia(array $site, int $wpPostId, int $mediaId): bool
{
try {
$this->client->post($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [
'auth' => [$site['api_user'], $site['api_token']],
'json' => ['featured_media' => $mediaId],
]);
return true;
} catch (GuzzleException $e) {
Logger::error("WP updatePostFeaturedMedia failed: " . $e->getMessage(), 'wordpress');
return false;
}
}
public function deleteMedia(array $site, int $mediaId): bool
{
try {
$this->client->delete($site['url'] . '/wp-json/wp/v2/media/' . $mediaId, [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['force' => true],
]);
return true;
} catch (GuzzleException $e) {
Logger::error("WP deleteMedia failed: " . $e->getMessage(), 'wordpress');
return false;
}
}
private function getMimeType(string $filename): string
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));