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:
@@ -5,6 +5,8 @@ namespace App\Controllers;
|
||||
use App\Core\Auth;
|
||||
use App\Core\Controller;
|
||||
use App\Models\Article;
|
||||
use App\Services\WordPressService;
|
||||
use App\Helpers\Logger;
|
||||
|
||||
class ArticleController extends Controller
|
||||
{
|
||||
@@ -41,4 +43,77 @@ class ArticleController extends Controller
|
||||
|
||||
$this->view('articles/show', ['article' => $article]);
|
||||
}
|
||||
|
||||
public function replaceImage(string $id): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$article = Article::findWithRelations((int) $id);
|
||||
if (!$article) {
|
||||
$this->json(['success' => false, 'message' => 'Artykuł nie znaleziony.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($article['wp_post_id'])) {
|
||||
$this->json(['success' => false, 'message' => 'Artykuł nie jest opublikowany w WordPress.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$site = [
|
||||
'url' => $article['site_url'],
|
||||
'api_user' => $article['site_api_user'],
|
||||
'api_token' => $article['site_api_token'],
|
||||
];
|
||||
|
||||
// Validate uploaded file
|
||||
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
|
||||
$this->json(['success' => false, 'message' => 'Nie przesłano pliku lub wystąpił błąd uploadu.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$file = $_FILES['image'];
|
||||
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mimeType, $allowedTypes)) {
|
||||
$this->json(['success' => false, 'message' => 'Niedozwolony format pliku. Dozwolone: JPG, PNG, GIF, WebP.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$imageData = file_get_contents($file['tmp_name']);
|
||||
$filename = $file['name'] ?: ('article-' . time() . '.jpg');
|
||||
|
||||
$wp = new WordPressService();
|
||||
|
||||
// 1. Delete old featured image from WP
|
||||
$oldMediaId = $wp->getPostFeaturedMedia($site, $article['wp_post_id']);
|
||||
if ($oldMediaId) {
|
||||
$wp->deleteMedia($site, $oldMediaId);
|
||||
Logger::info("Deleted old media ID: {$oldMediaId} for article {$id}", 'image');
|
||||
}
|
||||
|
||||
// 2. Upload new image to WP
|
||||
$newMediaId = $wp->uploadMedia($site, $imageData, $filename);
|
||||
if (!$newMediaId) {
|
||||
$this->json(['success' => false, 'message' => 'Nie udało się wgrać zdjęcia do WordPress.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Set as featured image on the post
|
||||
$updated = $wp->updatePostFeaturedMedia($site, $article['wp_post_id'], $newMediaId);
|
||||
if (!$updated) {
|
||||
$this->json(['success' => false, 'message' => 'Nie udało się ustawić zdjęcia jako wyróżniającego.']);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::info("Replaced image for article {$id}: old={$oldMediaId}, new={$newMediaId}", 'image');
|
||||
|
||||
$this->json([
|
||||
'success' => true,
|
||||
'message' => 'Zdjęcie zostało podmienione.',
|
||||
'media_id' => $newMediaId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Core\Auth;
|
||||
use App\Core\Controller;
|
||||
use App\Models\Site;
|
||||
use App\Models\Topic;
|
||||
use App\Services\WordPressService;
|
||||
|
||||
class CategoryController extends Controller
|
||||
@@ -23,9 +24,19 @@ class CategoryController extends Controller
|
||||
$wp = new WordPressService();
|
||||
$categories = $wp->getCategories($site);
|
||||
|
||||
$topics = Topic::findBySiteWithGlobal((int) $id);
|
||||
$hasTopicsWithoutCategory = false;
|
||||
foreach ($topics as $t) {
|
||||
if (!empty($t['global_topic_id']) && empty($t['wp_category_id'])) {
|
||||
$hasTopicsWithoutCategory = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->view('categories/index', [
|
||||
'site' => $site,
|
||||
'categories' => $categories,
|
||||
'hasTopicsWithoutCategory' => $hasTopicsWithoutCategory,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -51,4 +62,157 @@ class CategoryController extends Controller
|
||||
|
||||
$this->redirect("/sites/{$id}/categories");
|
||||
}
|
||||
|
||||
public function create(string $id): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$site = Site::find((int) $id);
|
||||
if (!$site) {
|
||||
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$name = trim($this->input('name', ''));
|
||||
if ($name === '') {
|
||||
$this->json(['success' => false, 'message' => 'Nazwa kategorii jest wymagana.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = (int) $this->input('parent', 0);
|
||||
|
||||
$wp = new WordPressService();
|
||||
$result = $wp->createCategory($site, $name, $parent);
|
||||
|
||||
if (!$result || !isset($result['id'])) {
|
||||
$this->json(['success' => false, 'message' => 'Nie udało się utworzyć kategorii w WordPress.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->json([
|
||||
'success' => true,
|
||||
'message' => "Utworzono kategorię \"{$result['name']}\" (ID: {$result['id']})",
|
||||
'category' => [
|
||||
'id' => $result['id'],
|
||||
'name' => $result['name'],
|
||||
'slug' => $result['slug'],
|
||||
'parent' => $result['parent'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function createFromTopics(string $id): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$site = Site::find((int) $id);
|
||||
if (!$site) {
|
||||
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$wp = new WordPressService();
|
||||
|
||||
// Get existing WP categories
|
||||
$existingCategories = $wp->getCategories($site);
|
||||
if ($existingCategories === false) {
|
||||
$this->json(['success' => false, 'message' => 'Nie udało się pobrać kategorii z WordPress.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Index existing by name (lowercase) for duplicate detection
|
||||
$existingByName = [];
|
||||
foreach ($existingCategories as $cat) {
|
||||
$existingByName[mb_strtolower($cat['name'])] = $cat;
|
||||
}
|
||||
|
||||
// Get site topics with global topic info
|
||||
$topics = Topic::findBySiteWithGlobal((int) $id);
|
||||
|
||||
// Group topics by parent category
|
||||
$groups = [];
|
||||
foreach ($topics as $topic) {
|
||||
if (empty($topic['global_topic_id'])) {
|
||||
continue; // skip custom topics without global link
|
||||
}
|
||||
$parentName = $topic['global_category_name'] ?? null;
|
||||
$childName = $topic['global_topic_name'] ?? $topic['name'];
|
||||
|
||||
if (!$parentName) {
|
||||
// Topic is linked to a top-level global topic (it IS a category, not a child)
|
||||
// Create as standalone category
|
||||
$parentName = $childName;
|
||||
$childName = null;
|
||||
}
|
||||
|
||||
if (!isset($groups[$parentName])) {
|
||||
$groups[$parentName] = [];
|
||||
}
|
||||
if ($childName) {
|
||||
$groups[$parentName][] = [
|
||||
'topic_id' => $topic['id'],
|
||||
'name' => $childName,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
$assigned = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($groups as $parentName => $children) {
|
||||
// Find or create parent category in WP
|
||||
$parentKey = mb_strtolower($parentName);
|
||||
if (isset($existingByName[$parentKey])) {
|
||||
$parentWpId = $existingByName[$parentKey]['id'];
|
||||
$skipped++;
|
||||
} else {
|
||||
$parentResult = $wp->createCategory($site, $parentName, 0);
|
||||
if (!$parentResult || !isset($parentResult['id'])) {
|
||||
$errors[] = "Nie udało się utworzyć kategorii \"{$parentName}\"";
|
||||
continue;
|
||||
}
|
||||
$parentWpId = $parentResult['id'];
|
||||
$existingByName[$parentKey] = $parentResult;
|
||||
$created++;
|
||||
}
|
||||
|
||||
// Create child categories
|
||||
foreach ($children as $child) {
|
||||
$childKey = mb_strtolower($child['name']);
|
||||
if (isset($existingByName[$childKey])) {
|
||||
$childWpId = $existingByName[$childKey]['id'];
|
||||
$skipped++;
|
||||
} else {
|
||||
$childResult = $wp->createCategory($site, $child['name'], $parentWpId);
|
||||
if (!$childResult || !isset($childResult['id'])) {
|
||||
$errors[] = "Nie udało się utworzyć podkategorii \"{$child['name']}\"";
|
||||
continue;
|
||||
}
|
||||
$childWpId = $childResult['id'];
|
||||
$existingByName[$childKey] = $childResult;
|
||||
$created++;
|
||||
}
|
||||
|
||||
// Assign wp_category_id to the topic
|
||||
Topic::update($child['topic_id'], ['wp_category_id' => $childWpId]);
|
||||
$assigned++;
|
||||
}
|
||||
}
|
||||
|
||||
$message = "Utworzono {$created} kategorii, pominięto {$skipped} istniejących, przypisano {$assigned} tematów.";
|
||||
if (!empty($errors)) {
|
||||
$message .= ' Błędy: ' . implode('; ', $errors);
|
||||
}
|
||||
|
||||
$this->json([
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'created' => $created,
|
||||
'skipped' => $skipped,
|
||||
'assigned' => $assigned,
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +95,16 @@ class GlobalTopicController extends Controller
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|
||||
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
||||
|
||||
GlobalTopic::delete((int) $id);
|
||||
|
||||
if ($isAjax) {
|
||||
$this->json(['success' => true, 'message' => 'Usunięto.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('success', 'Temat został usunięty.');
|
||||
$this->redirect('/global-topics');
|
||||
}
|
||||
|
||||
109
src/Controllers/InstallerController.php
Normal file
109
src/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Core\Controller;
|
||||
use App\Helpers\Validator;
|
||||
use App\Services\InstallerService;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
public function index(): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
$this->view('installer/index', []);
|
||||
}
|
||||
|
||||
public function install(): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$validator = new Validator();
|
||||
|
||||
// FTP
|
||||
$validator
|
||||
->required('ftp_host', $this->input('ftp_host'), 'Host FTP')
|
||||
->required('ftp_user', $this->input('ftp_user'), 'Użytkownik FTP')
|
||||
->required('ftp_pass', $this->input('ftp_pass'), 'Hasło FTP')
|
||||
->required('ftp_path', $this->input('ftp_path'), 'Ścieżka FTP');
|
||||
|
||||
// Database
|
||||
$validator
|
||||
->required('db_host', $this->input('db_host'), 'Host bazy danych')
|
||||
->required('db_name', $this->input('db_name'), 'Nazwa bazy danych')
|
||||
->required('db_user', $this->input('db_user'), 'Użytkownik bazy danych')
|
||||
->required('db_pass', $this->input('db_pass'), 'Hasło bazy danych');
|
||||
|
||||
// WordPress admin
|
||||
$validator
|
||||
->required('site_url', $this->input('site_url'), 'URL strony')
|
||||
->url('site_url', $this->input('site_url'), 'URL strony')
|
||||
->required('site_title', $this->input('site_title'), 'Tytuł strony')
|
||||
->required('admin_user', $this->input('admin_user'), 'Login administratora')
|
||||
->required('admin_pass', $this->input('admin_pass'), 'Hasło administratora')
|
||||
->minLength('admin_pass', $this->input('admin_pass'), 8, 'Hasło administratora')
|
||||
->required('admin_email', $this->input('admin_email'), 'E-mail administratora')
|
||||
->email('admin_email', $this->input('admin_email'), 'E-mail administratora');
|
||||
|
||||
if (!$validator->isValid()) {
|
||||
$this->json(['success' => false, 'message' => $validator->getFirstError()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$progressId = $this->input('progress_id', '');
|
||||
if (empty($progressId) || !preg_match('/^[a-zA-Z0-9]{10,30}$/', $progressId)) {
|
||||
$this->json(['success' => false, 'message' => 'Nieprawidłowy identyfikator postępu.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$config = [
|
||||
'ftp_host' => $this->input('ftp_host'),
|
||||
'ftp_user' => $this->input('ftp_user'),
|
||||
'ftp_pass' => $this->input('ftp_pass'),
|
||||
'ftp_path' => rtrim($this->input('ftp_path'), '/'),
|
||||
'ftp_port' => (int) ($this->input('ftp_port', '21')),
|
||||
'ftp_ssl' => (bool) $this->input('ftp_ssl'),
|
||||
'db_host' => $this->input('db_host'),
|
||||
'db_name' => $this->input('db_name'),
|
||||
'db_user' => $this->input('db_user'),
|
||||
'db_pass' => $this->input('db_pass'),
|
||||
'db_prefix' => $this->input('db_prefix', 'wp_'),
|
||||
'site_url' => rtrim($this->input('site_url'), '/'),
|
||||
'site_title' => $this->input('site_title'),
|
||||
'admin_user' => $this->input('admin_user'),
|
||||
'admin_pass' => $this->input('admin_pass'),
|
||||
'admin_email' => $this->input('admin_email'),
|
||||
'language' => $this->input('language', 'pl_PL'),
|
||||
];
|
||||
|
||||
$installer = new InstallerService();
|
||||
$result = $installer->install($config, $progressId);
|
||||
|
||||
// Cleanup progress file after completion
|
||||
InstallerService::cleanupProgress($progressId);
|
||||
|
||||
$this->json($result);
|
||||
}
|
||||
|
||||
public function status(string $id): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9]{10,30}$/', $id)) {
|
||||
$this->json(['percent' => 0, 'message' => 'Nieprawidłowy ID', 'status' => 'failed']);
|
||||
return;
|
||||
}
|
||||
|
||||
$progress = InstallerService::getProgress($id);
|
||||
|
||||
if ($progress === null) {
|
||||
$this->json(['percent' => 0, 'message' => 'Oczekiwanie na start...', 'status' => 'waiting']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->json($progress);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,42 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Auth;
|
||||
use App\Core\Config;
|
||||
use App\Core\Controller;
|
||||
use App\Helpers\Logger;
|
||||
use App\Models\Site;
|
||||
use App\Services\PublisherService;
|
||||
|
||||
class PublishController extends Controller
|
||||
{
|
||||
public function runByToken(): void
|
||||
{
|
||||
$configuredToken = (string) Config::get('PUBLISH_TRIGGER_TOKEN', '');
|
||||
$providedToken = (string) $this->input('token', '');
|
||||
|
||||
if ($providedToken === '') {
|
||||
$providedToken = (string) ($_SERVER['HTTP_X_PUBLISH_TOKEN'] ?? '');
|
||||
}
|
||||
|
||||
if ($configuredToken === '') {
|
||||
Logger::warning('Token publish trigger called, but PUBLISH_TRIGGER_TOKEN is not configured.', 'publish');
|
||||
$this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) {
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
Logger::warning("Invalid publish token attempt from {$ip}", 'publish');
|
||||
$this->json(['success' => false, 'message' => 'Forbidden'], 403);
|
||||
return;
|
||||
}
|
||||
|
||||
$publisher = new PublisherService();
|
||||
$result = $publisher->publishNext();
|
||||
|
||||
$this->json($result, 200);
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
Auth::requireLogin();
|
||||
@@ -29,8 +59,15 @@ class PublishController extends Controller
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|
||||
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
||||
|
||||
$site = Site::find((int) $id);
|
||||
if (!$site) {
|
||||
if ($isAjax) {
|
||||
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
|
||||
return;
|
||||
}
|
||||
$this->flash('danger', 'Strona nie znaleziona.');
|
||||
$this->redirect('/sites');
|
||||
return;
|
||||
@@ -39,6 +76,11 @@ class PublishController extends Controller
|
||||
$publisher = new PublisherService();
|
||||
$result = $publisher->publishForSite($site);
|
||||
|
||||
if ($isAjax) {
|
||||
$this->json($result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($result['success']) {
|
||||
$this->flash('success', $result['message']);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Controllers;
|
||||
use App\Core\Auth;
|
||||
use App\Core\Config;
|
||||
use App\Core\Controller;
|
||||
use App\Services\ImageService;
|
||||
use App\Services\OpenAIService;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
@@ -17,6 +19,17 @@ class SettingsController extends Controller
|
||||
'image_provider',
|
||||
'article_min_words',
|
||||
'article_max_words',
|
||||
'article_generation_prompt',
|
||||
'image_generation_prompt',
|
||||
];
|
||||
|
||||
private array $settingDefaults = [
|
||||
'openai_model' => 'gpt-4o',
|
||||
'image_provider' => 'freepik',
|
||||
'article_min_words' => '800',
|
||||
'article_max_words' => '1200',
|
||||
'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE,
|
||||
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
|
||||
];
|
||||
|
||||
public function index(): void
|
||||
@@ -25,7 +38,7 @@ class SettingsController extends Controller
|
||||
|
||||
$settings = [];
|
||||
foreach ($this->settingKeys as $key) {
|
||||
$settings[$key] = Config::getDbSetting($key, '');
|
||||
$settings[$key] = Config::getDbSetting($key, $this->settingDefaults[$key] ?? '');
|
||||
}
|
||||
|
||||
$this->view('settings/index', ['settings' => $settings]);
|
||||
|
||||
@@ -56,7 +56,7 @@ class SiteController extends Controller
|
||||
'url' => rtrim($this->input('url'), '/'),
|
||||
'api_user' => $this->input('api_user'),
|
||||
'api_token' => $this->input('api_token'),
|
||||
'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)),
|
||||
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
||||
'is_active' => $this->input('is_active') ? 1 : 0,
|
||||
'is_multisite' => $this->input('is_multisite') ? 1 : 0,
|
||||
]);
|
||||
@@ -128,9 +128,22 @@ class SiteController extends Controller
|
||||
'url' => rtrim($this->input('url'), '/'),
|
||||
'api_user' => $this->input('api_user'),
|
||||
'api_token' => $this->input('api_token'),
|
||||
'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)),
|
||||
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
|
||||
'is_active' => $this->input('is_active') ? 1 : 0,
|
||||
'is_multisite' => $this->input('is_multisite') ? 1 : 0,
|
||||
'ftp_host' => $this->input('ftp_host') ?: null,
|
||||
'ftp_port' => $this->input('ftp_port') ? (int) $this->input('ftp_port') : null,
|
||||
'ftp_user' => $this->input('ftp_user') ?: null,
|
||||
'ftp_pass' => $this->input('ftp_pass') ?: null,
|
||||
'ftp_path' => $this->input('ftp_path') ?: null,
|
||||
'db_host' => $this->input('db_host') ?: null,
|
||||
'db_name' => $this->input('db_name') ?: null,
|
||||
'db_user' => $this->input('db_user') ?: null,
|
||||
'db_pass' => $this->input('db_pass') ?: null,
|
||||
'db_prefix' => $this->input('db_prefix') ?: null,
|
||||
'wp_admin_user' => $this->input('wp_admin_user') ?: null,
|
||||
'wp_admin_pass' => $this->input('wp_admin_pass') ?: null,
|
||||
'wp_admin_email' => $this->input('wp_admin_email') ?: null,
|
||||
]);
|
||||
|
||||
$this->flash('success', 'Strona została zaktualizowana.');
|
||||
|
||||
@@ -81,8 +81,15 @@ class TopicController extends Controller
|
||||
{
|
||||
Auth::requireLogin();
|
||||
|
||||
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|
||||
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
||||
|
||||
$topic = Topic::find((int) $id);
|
||||
if (!$topic) {
|
||||
if ($isAjax) {
|
||||
$this->json(['success' => false, 'message' => 'Temat nie znaleziony.']);
|
||||
return;
|
||||
}
|
||||
$this->flash('danger', 'Temat nie znaleziony.');
|
||||
$this->redirect('/sites');
|
||||
return;
|
||||
@@ -91,6 +98,11 @@ class TopicController extends Controller
|
||||
$siteId = $topic['site_id'];
|
||||
Topic::delete((int) $id);
|
||||
|
||||
if ($isAjax) {
|
||||
$this->json(['success' => true, 'message' => 'Temat został usunięty.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->flash('success', 'Temat został usunięty.');
|
||||
$this->redirect("/sites/{$siteId}/topics");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ class Validator
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function email(string $field, mixed $value, string $label = ''): self
|
||||
{
|
||||
if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->errors[$field] = ($label ?: $field) . ' musi być prawidłowym adresem e-mail.';
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return empty($this->errors);
|
||||
|
||||
@@ -42,7 +42,8 @@ class Article extends Model
|
||||
public static function findWithRelations(int $id): ?array
|
||||
{
|
||||
$stmt = self::db()->prepare(
|
||||
"SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url
|
||||
"SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url,
|
||||
s.api_user as site_api_user, s.api_token as site_api_token
|
||||
FROM articles a
|
||||
JOIN topics t ON a.topic_id = t.id
|
||||
JOIN sites s ON a.site_id = s.id
|
||||
|
||||
@@ -19,7 +19,7 @@ class Site extends Model
|
||||
WHERE is_active = 1
|
||||
AND (
|
||||
last_published_at IS NULL
|
||||
OR DATE_ADD(last_published_at, INTERVAL publish_interval_days DAY) <= NOW()
|
||||
OR DATE_ADD(last_published_at, INTERVAL publish_interval_hours HOUR) <= NOW()
|
||||
)
|
||||
ORDER BY last_published_at ASC";
|
||||
$stmt = self::db()->query($sql);
|
||||
|
||||
@@ -22,7 +22,7 @@ class Topic extends Model
|
||||
LEFT JOIN global_topics g ON t.global_topic_id = g.id
|
||||
LEFT JOIN global_topics gp ON g.parent_id = gp.id
|
||||
WHERE t.site_id = :site_id
|
||||
ORDER BY t.name ASC"
|
||||
ORDER BY COALESCE(gp.name, g.name, t.name) ASC, t.name ASC"
|
||||
);
|
||||
$stmt->execute(['site_id' => $siteId]);
|
||||
return $stmt->fetchAll();
|
||||
|
||||
140
src/Services/FtpService.php
Normal file
140
src/Services/FtpService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
442
src/Services/InstallerService.php
Normal file
442
src/Services/InstallerService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user