1611 lines
59 KiB
PHP
1611 lines
59 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use GuzzleHttp\Client;
|
||
use GuzzleHttp\Exception\GuzzleException;
|
||
use GuzzleHttp\Exception\RequestException;
|
||
use App\Helpers\Logger;
|
||
use App\Models\Site;
|
||
|
||
class WordPressService
|
||
{
|
||
private const BACKPRO_MU_PLUGIN_FILENAME = 'backpro-remote-tools.php';
|
||
private const BACKPRO_REMOTE_SERVICE_FILENAME = 'backpro-remote-service.php';
|
||
private const BACKPRO_REMOTE_SERVICE_VERSION = '1.5.0';
|
||
private const BACKPRO_NEWS_THEME_SLUG = 'backpro-news-mag';
|
||
private const BACKPRO_NEWS_THEME_SOURCE_DIR = 'assets/wp-theme-backpro-news';
|
||
private Client $client;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->client = new Client(['timeout' => 30]);
|
||
}
|
||
|
||
public function testConnection(array $site): array
|
||
{
|
||
try {
|
||
$options = ['query' => ['per_page' => 1]];
|
||
$auth = $this->buildAuthOption($site);
|
||
if ($auth !== null) {
|
||
$options['auth'] = $auth;
|
||
}
|
||
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/posts', $options);
|
||
|
||
if ($response->getStatusCode() === 200) {
|
||
return ['success' => true, 'message' => 'Połączenie OK'];
|
||
}
|
||
|
||
return ['success' => false, 'message' => 'Nieoczekiwany kod: ' . $response->getStatusCode()];
|
||
} catch (GuzzleException $e) {
|
||
Logger::error("WP test connection failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return ['success' => false, 'message' => 'Błąd: ' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
public function getCategories(array $site): array|false
|
||
{
|
||
try {
|
||
$options = ['query' => ['per_page' => 100]];
|
||
$auth = $this->buildAuthOption($site);
|
||
if ($auth !== null) {
|
||
$options['auth'] = $auth;
|
||
}
|
||
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/categories', $options);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (!is_array($data)) {
|
||
return false;
|
||
}
|
||
|
||
if (isset($data['code']) && isset($data['message'])) {
|
||
Logger::error("WP getCategories API error for {$site['url']}: {$data['code']} {$data['message']}", 'wordpress');
|
||
return false;
|
||
}
|
||
|
||
return $data;
|
||
} catch (GuzzleException $e) {
|
||
Logger::error("WP getCategories failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function createCategory(array $site, string $name, int $parent = 0): ?array
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'createCategory');
|
||
if ($auth === null) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$response = $this->requestWp($site, 'POST', 'wp/v2/categories', [
|
||
'auth' => $auth,
|
||
'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
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'uploadMedia');
|
||
if ($auth === null) {
|
||
return null;
|
||
}
|
||
|
||
// Try REST API first.
|
||
try {
|
||
$response = $this->requestWp($site, 'POST', 'wp/v2/media', [
|
||
'auth' => $auth,
|
||
'headers' => [
|
||
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
|
||
'Content-Type' => $this->getMimeType($filename),
|
||
],
|
||
'body' => $imageData,
|
||
]);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (is_array($data) && isset($data['id'])) {
|
||
return (int) $data['id'];
|
||
}
|
||
|
||
Logger::warning("WP REST uploadMedia returned unexpected response for {$site['url']}, trying XML-RPC.", 'wordpress');
|
||
} catch (GuzzleException $e) {
|
||
Logger::warning("WP REST uploadMedia failed for {$site['url']}, trying XML-RPC: " . $e->getMessage(), 'wordpress');
|
||
}
|
||
|
||
// Fall back to XML-RPC.
|
||
return $this->uploadMediaXmlRpc($site, $auth, $imageData, $filename);
|
||
}
|
||
|
||
public function createPost(
|
||
array $site,
|
||
string $title,
|
||
string $content,
|
||
?int $categoryId = null,
|
||
?int $mediaId = null,
|
||
?string $excerpt = null
|
||
): ?int {
|
||
$auth = $this->requireAuthOption($site, 'createPost');
|
||
if ($auth === null) {
|
||
return null;
|
||
}
|
||
|
||
// Try REST API first.
|
||
try {
|
||
$postData = [
|
||
'title' => $title,
|
||
'content' => $content,
|
||
'status' => 'publish',
|
||
];
|
||
|
||
if ($categoryId) {
|
||
$postData['categories'] = [$categoryId];
|
||
}
|
||
|
||
if ($mediaId) {
|
||
$postData['featured_media'] = $mediaId;
|
||
}
|
||
|
||
if (is_string($excerpt) && trim($excerpt) !== '') {
|
||
$postData['excerpt'] = trim($excerpt);
|
||
}
|
||
|
||
$response = $this->requestWp($site, 'POST', 'wp/v2/posts', [
|
||
'auth' => $auth,
|
||
'json' => $postData,
|
||
]);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (is_array($data) && isset($data['id'])) {
|
||
return (int) $data['id'];
|
||
}
|
||
|
||
Logger::warning("WP REST createPost returned unexpected response for {$site['url']}, trying XML-RPC.", 'wordpress');
|
||
} catch (GuzzleException $e) {
|
||
Logger::warning("WP REST createPost failed for {$site['url']}, trying XML-RPC: " . $e->getMessage(), 'wordpress');
|
||
}
|
||
|
||
// Fall back to XML-RPC.
|
||
return $this->createPostXmlRpc($site, $auth, $title, $content, $categoryId, $mediaId, $excerpt);
|
||
}
|
||
|
||
public function getPublishedPosts(array $site, int $perPage = 100): array|false
|
||
{
|
||
$perPage = max(1, min($perPage, 100));
|
||
$allPosts = [];
|
||
$page = 1;
|
||
$totalPages = 1;
|
||
$auth = $this->buildAuthOption($site);
|
||
|
||
try {
|
||
do {
|
||
$query = [
|
||
'status' => 'publish',
|
||
'per_page' => $perPage,
|
||
'page' => $page,
|
||
'_fields' => 'id,title,content,date,categories,link',
|
||
];
|
||
$options = ['query' => $query];
|
||
if ($auth !== null) {
|
||
$options['auth'] = $auth;
|
||
}
|
||
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/posts', $options);
|
||
$batch = json_decode($response->getBody()->getContents(), true);
|
||
|
||
// Some hosts return HTML/invalid payload when invalid basic auth is sent.
|
||
if ((!is_array($batch) || (isset($batch['code']) && isset($batch['message']))) && $auth !== null) {
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/posts', ['query' => $query]);
|
||
$batch = json_decode($response->getBody()->getContents(), true);
|
||
}
|
||
|
||
if (!is_array($batch)) {
|
||
throw new \RuntimeException('Invalid JSON response from WordPress posts endpoint.');
|
||
}
|
||
if (isset($batch['code']) && isset($batch['message'])) {
|
||
throw new \RuntimeException("WordPress API error: {$batch['code']} {$batch['message']}");
|
||
}
|
||
|
||
$allPosts = array_merge($allPosts, $batch);
|
||
|
||
$totalPages = max(1, (int) $response->getHeaderLine('X-WP-TotalPages'));
|
||
$page++;
|
||
} while ($page <= $totalPages);
|
||
|
||
return $allPosts;
|
||
} catch (\Throwable $e) {
|
||
Logger::error("WP getPublishedPosts failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function deletePost(array $site, int $wpPostId): bool
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'deletePost');
|
||
if ($auth === null) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
$this->requestWp($site, 'DELETE', 'wp/v2/posts/' . $wpPostId, [
|
||
'auth' => $auth,
|
||
'query' => ['force' => true],
|
||
]);
|
||
return true;
|
||
} catch (GuzzleException $e) {
|
||
if ($e instanceof RequestException && $e->hasResponse()) {
|
||
$status = $e->getResponse()->getStatusCode();
|
||
if (in_array($status, [404, 410], true)) {
|
||
Logger::warning("WP post {$wpPostId} already removed for {$site['url']}", 'wordpress');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
Logger::error("WP deletePost failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function getPostFeaturedMedia(array $site, int $wpPostId): ?int
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'getPostFeaturedMedia');
|
||
if ($auth === null) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/posts/' . $wpPostId, [
|
||
'auth' => $auth,
|
||
'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
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'updatePostFeaturedMedia');
|
||
if ($auth === null) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
$this->requestWp($site, 'POST', 'wp/v2/posts/' . $wpPostId, [
|
||
'auth' => $auth,
|
||
'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
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'deleteMedia');
|
||
if ($auth === null) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
$this->requestWp($site, 'DELETE', 'wp/v2/media/' . $mediaId, [
|
||
'auth' => $auth,
|
||
'query' => ['force' => true],
|
||
]);
|
||
return true;
|
||
} catch (GuzzleException $e) {
|
||
Logger::error("WP deleteMedia failed: " . $e->getMessage(), 'wordpress');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public function getPermalinkSettings(array $site): array
|
||
{
|
||
$result = $this->callRemoteService($site, 'get_permalink');
|
||
if (!empty($result['success'])) {
|
||
return [
|
||
'success' => true,
|
||
'permalink_structure' => (string) ($result['permalink_structure'] ?? ''),
|
||
'pretty_enabled' => (bool) ($result['pretty_enabled'] ?? false),
|
||
'source' => 'backpro_remote_service',
|
||
'message' => (string) ($result['message'] ?? 'OK'),
|
||
];
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (!empty($ensure['success'])) {
|
||
$refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$retry = $this->callRemoteService($refreshedSite, 'get_permalink');
|
||
if (!empty($retry['success'])) {
|
||
return [
|
||
'success' => true,
|
||
'permalink_structure' => (string) ($retry['permalink_structure'] ?? ''),
|
||
'pretty_enabled' => (bool) ($retry['pretty_enabled'] ?? false),
|
||
'source' => 'backpro_remote_service',
|
||
'message' => (string) ($retry['message'] ?? 'OK'),
|
||
];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'code' => 'endpoint_missing',
|
||
'message' => (string) ($ensure['message'] ?? $result['message'] ?? 'Brak endpointu BackPRO na WordPress.'),
|
||
];
|
||
}
|
||
|
||
public function enablePrettyPermalinks(array $site): array
|
||
{
|
||
$result = $this->removeIndexPhpFromPermalinks($site);
|
||
$this->ensureHtaccessForPrettyPermalinks($site);
|
||
|
||
// Try a dedicated rewrite refresh even when structure did not change.
|
||
$flush = $this->callRemoteService($site, 'flush_rewrite');
|
||
if (empty($flush['success'])) {
|
||
Logger::warning(
|
||
"Remote flush_rewrite failed for {$site['url']}: " . ($flush['message'] ?? 'unknown'),
|
||
'wordpress'
|
||
);
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
public function removeIndexPhpFromPermalinks(array $site): array
|
||
{
|
||
$status = $this->getPermalinkSettings($site);
|
||
if (empty($status['success'])) {
|
||
return $status;
|
||
}
|
||
|
||
$current = (string) ($status['permalink_structure'] ?? '');
|
||
if ($current === '') {
|
||
return $this->setPermalinkStructure($site, '/%postname%/');
|
||
}
|
||
|
||
$updated = preg_replace('#^/?index\.php/?#i', '/', $current);
|
||
$updated = is_string($updated) ? $updated : $current;
|
||
$updated = '/' . ltrim($updated, '/');
|
||
$updated = preg_replace('#/+#', '/', $updated) ?? $updated;
|
||
|
||
if ($updated === $current || !str_contains($current, 'index.php')) {
|
||
$this->ensureHtaccessForPrettyPermalinks($site);
|
||
$flush = $this->callRemoteService($site, 'flush_rewrite');
|
||
if (empty($flush['success'])) {
|
||
Logger::warning(
|
||
"Remote flush_rewrite (no-index branch) failed for {$site['url']}: " . ($flush['message'] ?? 'unknown'),
|
||
'wordpress'
|
||
);
|
||
}
|
||
return [
|
||
'success' => true,
|
||
'permalink_structure' => $current,
|
||
'pretty_enabled' => true,
|
||
'message' => 'Struktura permalink juz nie zawiera index.php. Odswiezono rewrite i .htaccess.',
|
||
];
|
||
}
|
||
|
||
return $this->setPermalinkStructure($site, $updated);
|
||
}
|
||
|
||
public function setPermalinkStructure(array $site, string $structure): array
|
||
{
|
||
$result = $this->callRemoteService($site, 'set_permalink', ['structure' => $structure]);
|
||
if (!empty($result['success'])) {
|
||
$this->ensureHtaccessForPrettyPermalinks($site);
|
||
return [
|
||
'success' => true,
|
||
'message' => (string) ($result['message'] ?? 'Zmieniono strukturę permalink.'),
|
||
'permalink_structure' => (string) ($result['permalink_structure'] ?? $structure),
|
||
'pretty_enabled' => (bool) ($result['pretty_enabled'] ?? true),
|
||
];
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (empty($ensure['success'])) {
|
||
return ['success' => false, 'message' => (string) ($ensure['message'] ?? 'Brak endpointu BackPRO na WordPress.')];
|
||
}
|
||
|
||
$refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$retry = $this->callRemoteService($refreshedSite, 'set_permalink', ['structure' => $structure]);
|
||
|
||
if (!empty($retry['success'])) {
|
||
$this->ensureHtaccessForPrettyPermalinks($refreshedSite);
|
||
return [
|
||
'success' => true,
|
||
'message' => (string) ($retry['message'] ?? 'Zmieniono strukturę permalink.'),
|
||
'permalink_structure' => (string) ($retry['permalink_structure'] ?? $structure),
|
||
'pretty_enabled' => (bool) ($retry['pretty_enabled'] ?? true),
|
||
];
|
||
}
|
||
|
||
return ['success' => false, 'message' => (string) ($retry['message'] ?? 'Blad zmiany permalink.')];
|
||
}
|
||
|
||
public function getPostLink(array $site, int $wpPostId): ?string
|
||
{
|
||
if ($wpPostId <= 0) {
|
||
return null;
|
||
}
|
||
|
||
$auth = $this->buildAuthOption($site);
|
||
$options = ['query' => ['_fields' => 'link']];
|
||
if ($auth !== null) {
|
||
$options['auth'] = $auth;
|
||
}
|
||
|
||
try {
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/posts/' . $wpPostId, $options);
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
$link = trim((string) ($data['link'] ?? ''));
|
||
return $link !== '' ? $link : null;
|
||
} catch (GuzzleException $e) {
|
||
Logger::warning("WP getPostLink failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
public function enableSearchEngineIndexing(array $site): array
|
||
{
|
||
$result = $this->callRemoteService($site, 'set_blog_public', ['blog_public' => '1']);
|
||
if (!empty($result['success'])) {
|
||
return [
|
||
'success' => true,
|
||
'blog_public' => (int) ($result['blog_public'] ?? 1),
|
||
'message' => (string) ($result['message'] ?? 'Wlaczono indeksowanie strony.'),
|
||
];
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (empty($ensure['success'])) {
|
||
return [
|
||
'success' => false,
|
||
'message' => (string) ($ensure['message'] ?? $result['message'] ?? 'Brak endpointu BackPRO na WordPress.'),
|
||
];
|
||
}
|
||
|
||
$refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$retry = $this->callRemoteService($refreshedSite, 'set_blog_public', ['blog_public' => '1']);
|
||
if (!empty($retry['success'])) {
|
||
return [
|
||
'success' => true,
|
||
'blog_public' => (int) ($retry['blog_public'] ?? 1),
|
||
'message' => (string) ($retry['message'] ?? 'Wlaczono indeksowanie strony.'),
|
||
];
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'message' => (string) ($retry['message'] ?? 'Nie udalo sie wlaczyc indeksowania strony.'),
|
||
];
|
||
}
|
||
|
||
public function getCommentSettings(array $site): array
|
||
{
|
||
$result = $this->callRemoteService($site, 'get_comment_settings');
|
||
if (!empty($result['success'])) {
|
||
return $this->formatCommentSettings($result);
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (empty($ensure['success'])) {
|
||
return [
|
||
'success' => false,
|
||
'comments_enabled' => false,
|
||
'default_comment_status' => '',
|
||
'message' => (string) ($ensure['message'] ?? $result['message'] ?? 'Brak endpointu BackPRO na WordPress.'),
|
||
];
|
||
}
|
||
|
||
$refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$retry = $this->callRemoteService($refreshedSite, 'get_comment_settings');
|
||
if (!empty($retry['success'])) {
|
||
return $this->formatCommentSettings($retry);
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'comments_enabled' => false,
|
||
'default_comment_status' => '',
|
||
'message' => (string) ($retry['message'] ?? 'Nie udalo sie pobrac ustawien komentarzy.'),
|
||
];
|
||
}
|
||
|
||
public function setCommentsEnabled(array $site, bool $enabled): array
|
||
{
|
||
$params = ['comments_enabled' => $enabled ? '1' : '0'];
|
||
$result = $this->callRemoteService($site, 'set_comment_settings', $params);
|
||
if (!empty($result['success'])) {
|
||
return $this->formatCommentSettings($result);
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (empty($ensure['success'])) {
|
||
return [
|
||
'success' => false,
|
||
'comments_enabled' => !$enabled,
|
||
'default_comment_status' => '',
|
||
'message' => (string) ($ensure['message'] ?? $result['message'] ?? 'Brak endpointu BackPRO na WordPress.'),
|
||
];
|
||
}
|
||
|
||
$refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$retry = $this->callRemoteService($refreshedSite, 'set_comment_settings', $params);
|
||
if (!empty($retry['success'])) {
|
||
return $this->formatCommentSettings($retry);
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'comments_enabled' => !$enabled,
|
||
'default_comment_status' => '',
|
||
'message' => (string) ($retry['message'] ?? 'Nie udalo sie zapisac ustawien komentarzy.'),
|
||
];
|
||
}
|
||
|
||
public function getComments(array $site, string $status = 'all', int $page = 1, int $perPage = 20): array
|
||
{
|
||
$auth = $this->requireAuthOption($site, 'getComments');
|
||
if ($auth === null) {
|
||
return $this->buildCommentsFailure(1, 'Brak danych API WordPress dla tej strony.');
|
||
}
|
||
|
||
$page = max(1, $page);
|
||
$perPage = max(1, min($perPage, 100));
|
||
|
||
try {
|
||
$response = $this->requestWp($site, 'GET', 'wp/v2/comments', [
|
||
'auth' => $auth,
|
||
'query' => $this->buildCommentsQuery($status, $page, $perPage),
|
||
]);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (!is_array($data)) {
|
||
throw new \RuntimeException('Invalid JSON response from WordPress comments endpoint.');
|
||
}
|
||
|
||
if (isset($data['code']) && isset($data['message'])) {
|
||
throw new \RuntimeException("WordPress API error: {$data['code']} {$data['message']}");
|
||
}
|
||
|
||
return $this->buildCommentsSuccess($response, $data, $page);
|
||
} catch (RequestException $e) {
|
||
Logger::error("WP getComments failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return $this->buildCommentsFailure(
|
||
$page,
|
||
$this->formatWpRequestError($e, 'Nie udalo sie pobrac komentarzy.')
|
||
);
|
||
} catch (\Throwable $e) {
|
||
Logger::error("WP getComments failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return $this->buildCommentsFailure($page, 'Nie udalo sie pobrac komentarzy: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
public function deleteComment(array $site, int $commentId): array
|
||
{
|
||
if ($commentId <= 0) {
|
||
return ['success' => false, 'message' => 'Nieprawidlowy identyfikator komentarza.'];
|
||
}
|
||
|
||
$auth = $this->requireAuthOption($site, 'deleteComment');
|
||
if ($auth === null) {
|
||
return ['success' => false, 'message' => 'Brak danych API WordPress dla tej strony.'];
|
||
}
|
||
|
||
try {
|
||
$response = $this->requestWp($site, 'DELETE', 'wp/v2/comments/' . $commentId, [
|
||
'auth' => $auth,
|
||
'query' => ['force' => true],
|
||
]);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (is_array($data) && (isset($data['deleted']) || isset($data['previous']) || isset($data['id']))) {
|
||
return ['success' => true, 'message' => 'Komentarz zostal usuniety.'];
|
||
}
|
||
|
||
return ['success' => true, 'message' => 'Komentarz zostal usuniety.'];
|
||
} catch (RequestException $e) {
|
||
Logger::error("WP deleteComment failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
$statusCode = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0;
|
||
if ($statusCode === 404) {
|
||
return ['success' => false, 'message' => 'Komentarz nie istnieje albo zostal juz usuniety.'];
|
||
}
|
||
|
||
return [
|
||
'success' => false,
|
||
'message' => $this->formatWpRequestError($e, 'Nie udalo sie usunac komentarza.'),
|
||
];
|
||
} catch (GuzzleException $e) {
|
||
Logger::error("WP deleteComment failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return ['success' => false, 'message' => 'Nie udalo sie usunac komentarza: ' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
public function ensureRemoteService(array $site): array
|
||
{
|
||
$siteData = $this->prepareRemoteServiceMetadata($site);
|
||
$probe = $this->callRemoteService($siteData, 'ping');
|
||
$remoteVersion = (string) ($probe['version'] ?? '');
|
||
if (!empty($probe['success']) && $remoteVersion === self::BACKPRO_REMOTE_SERVICE_VERSION) {
|
||
return ['success' => true, 'message' => 'Remote service juz aktywny.'];
|
||
}
|
||
|
||
if (!empty($probe['success'])) {
|
||
Logger::info(
|
||
"Remote service version mismatch for {$siteData['url']}: remote={$remoteVersion}, local=" . self::BACKPRO_REMOTE_SERVICE_VERSION,
|
||
'wordpress'
|
||
);
|
||
}
|
||
|
||
return $this->installBackproRemoteService($siteData);
|
||
}
|
||
|
||
public function getRemoteServiceStatus(array $site): array
|
||
{
|
||
$siteData = $this->prepareRemoteServiceMetadata($site);
|
||
$probe = $this->callRemoteService($siteData, 'ping');
|
||
|
||
return [
|
||
'success' => !empty($probe['success']),
|
||
'local_version' => self::BACKPRO_REMOTE_SERVICE_VERSION,
|
||
'remote_version' => (string) ($probe['version'] ?? 'brak'),
|
||
'service_url' => $this->buildRemoteServiceUrl($siteData),
|
||
'message' => (string) ($probe['message'] ?? 'Brak odpowiedzi pliku serwisowego.'),
|
||
];
|
||
}
|
||
|
||
public function installBackproNewsTheme(array $site): array
|
||
{
|
||
$ftpHost = trim((string) ($site['ftp_host'] ?? ''));
|
||
$ftpUser = trim((string) ($site['ftp_user'] ?? ''));
|
||
$ftpPass = (string) ($site['ftp_pass'] ?? '');
|
||
$ftpPort = (int) ($site['ftp_port'] ?? 21);
|
||
|
||
if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') {
|
||
return ['success' => false, 'message' => 'Brak danych FTP. Uzupelnij je w ustawieniach strony.'];
|
||
}
|
||
|
||
$themeSourceDir = dirname(__DIR__, 2) . '/' . self::BACKPRO_NEWS_THEME_SOURCE_DIR;
|
||
if (!is_dir($themeSourceDir)) {
|
||
return ['success' => false, 'message' => 'Brak lokalnych plikow motywu BackPRO News.'];
|
||
}
|
||
|
||
$ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21);
|
||
|
||
$basePath = trim((string) ($site['ftp_path'] ?? ''), "/ \t\n\r\0\x0B");
|
||
$remoteThemesDir = ($basePath !== '' ? '/' . $basePath : '') . '/wp-content/themes';
|
||
$remoteThemeDir = $remoteThemesDir . '/' . self::BACKPRO_NEWS_THEME_SLUG;
|
||
|
||
try {
|
||
$ftp->connect();
|
||
$ftp->ensureDirectory($remoteThemesDir);
|
||
$ftp->ensureDirectory($remoteThemeDir);
|
||
$ftp->deleteDirectoryContents($remoteThemeDir);
|
||
$ftp->uploadDirectory($themeSourceDir, $remoteThemeDir);
|
||
$ftp->disconnect();
|
||
} catch (\Throwable $e) {
|
||
$ftp->disconnect();
|
||
Logger::error("BackPRO News theme upload failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
return ['success' => false, 'message' => 'Nie udalo sie wgrac motywu: ' . $e->getMessage()];
|
||
}
|
||
|
||
$ensure = $this->ensureRemoteService($site);
|
||
if (empty($ensure['success'])) {
|
||
return ['success' => false, 'message' => 'Motyw wgrany, ale nie udalo sie przygotowac pliku serwisowego: ' . ($ensure['message'] ?? '')];
|
||
}
|
||
|
||
$siteData = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site;
|
||
$activate = $this->callRemoteService($siteData, 'activate_theme', [
|
||
'stylesheet' => self::BACKPRO_NEWS_THEME_SLUG,
|
||
]);
|
||
|
||
if (empty($activate['success'])) {
|
||
return ['success' => false, 'message' => 'Motyw wgrany, ale aktywacja nieudana: ' . ($activate['message'] ?? 'unknown')];
|
||
}
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => 'Zainstalowano i aktywowano motyw BackPRO News. Sekcje kategorii na stronie glownej tworza sie automatycznie.',
|
||
];
|
||
}
|
||
|
||
public function installBackproRemoteService(array $site): array
|
||
{
|
||
$ftpHost = trim((string) ($site['ftp_host'] ?? ''));
|
||
$ftpUser = trim((string) ($site['ftp_user'] ?? ''));
|
||
$ftpPass = (string) ($site['ftp_pass'] ?? '');
|
||
$ftpPort = (int) ($site['ftp_port'] ?? 21);
|
||
|
||
if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') {
|
||
return [
|
||
'success' => false,
|
||
'message' => 'Brak danych FTP. Uzupelnij je w ustawieniach strony.',
|
||
];
|
||
}
|
||
|
||
$siteData = $this->prepareRemoteServiceMetadata($site);
|
||
$serviceFile = (string) $siteData['remote_service_file'];
|
||
$serviceToken = (string) $siteData['remote_service_token'];
|
||
|
||
$ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21);
|
||
$tmpFile = tempnam(sys_get_temp_dir(), 'backpro_remote_');
|
||
|
||
if ($tmpFile === false) {
|
||
return ['success' => false, 'message' => 'Nie udalo sie przygotowac pliku serwisowego.'];
|
||
}
|
||
|
||
$basePath = trim((string) ($siteData['ftp_path'] ?? ''), "/ \t\n\r\0\x0B");
|
||
$remoteDir = ($basePath !== '' ? '/' . $basePath : '');
|
||
$remotePath = rtrim($remoteDir, '/') . '/' . $serviceFile;
|
||
|
||
try {
|
||
file_put_contents($tmpFile, $this->getBackproRemoteServiceContent($serviceToken));
|
||
$ftp->connect();
|
||
$ftp->ensureDirectory($remoteDir === '' ? '/' : $remoteDir);
|
||
$ftp->uploadFile($tmpFile, $remotePath);
|
||
$ftp->disconnect();
|
||
|
||
if (!empty($siteData['id'])) {
|
||
try {
|
||
Site::update((int) $siteData['id'], [
|
||
'remote_service_file' => $serviceFile,
|
||
'remote_service_token' => $serviceToken,
|
||
'remote_service_installed_at' => date('Y-m-d H:i:s'),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
Logger::warning('Could not persist remote service installation metadata: ' . $e->getMessage(), 'wordpress');
|
||
}
|
||
}
|
||
|
||
Logger::info("BackPRO remote service uploaded to {$siteData['url']} ({$remotePath})", 'wordpress');
|
||
return ['success' => true, 'message' => 'Zainstalowano plik serwisowy BackPRO na WordPress.'];
|
||
} catch (\Throwable $e) {
|
||
Logger::error("BackPRO remote service upload failed for {$siteData['url']}: " . $e->getMessage(), 'wordpress');
|
||
return ['success' => false, 'message' => 'Nie udalo sie wgrac pliku serwisowego: ' . $e->getMessage()];
|
||
} finally {
|
||
if (is_file($tmpFile)) {
|
||
@unlink($tmpFile);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── XML-RPC fallback methods ──────────────────────────────────────
|
||
|
||
private function createPostXmlRpc(
|
||
array $site,
|
||
array $auth,
|
||
string $title,
|
||
string $content,
|
||
?int $categoryId,
|
||
?int $mediaId,
|
||
?string $excerpt
|
||
): ?int
|
||
{
|
||
$fields = '<member><name>post_title</name><value><string>' . $this->xmlEsc($title) . '</string></value></member>'
|
||
. '<member><name>post_content</name><value><string>' . $this->xmlEsc($content) . '</string></value></member>'
|
||
. '<member><name>post_status</name><value><string>publish</string></value></member>';
|
||
|
||
if (is_string($excerpt) && trim($excerpt) !== '') {
|
||
$fields .= '<member><name>mt_excerpt</name><value><string>' . $this->xmlEsc(trim($excerpt)) . '</string></value></member>';
|
||
}
|
||
|
||
if ($categoryId) {
|
||
$fields .= '<member><name>terms</name><value><struct>'
|
||
. '<member><name>category</name><value><array><data>'
|
||
. '<value><i4>' . (int) $categoryId . '</i4></value>'
|
||
. '</data></array></value></member>'
|
||
. '</struct></value></member>';
|
||
}
|
||
|
||
if ($mediaId) {
|
||
$fields .= '<member><name>post_thumbnail</name><value><i4>' . (int) $mediaId . '</i4></value></member>';
|
||
}
|
||
|
||
$xml = $this->xmlRpcEnvelope('wp.newPost', $auth, '<param><value><struct>' . $fields . '</struct></value></param>');
|
||
|
||
$result = $this->sendXmlRpc($site['url'], $xml);
|
||
if ($result === null) {
|
||
return null;
|
||
}
|
||
|
||
$postId = (int) $result;
|
||
if ($postId <= 0) {
|
||
Logger::error("WP XML-RPC createPost returned invalid id for {$site['url']}.", 'wordpress');
|
||
return null;
|
||
}
|
||
|
||
Logger::info("WP XML-RPC createPost success for {$site['url']}: post_id={$postId}", 'wordpress');
|
||
return $postId;
|
||
}
|
||
|
||
private function uploadMediaXmlRpc(array $site, array $auth, string $imageData, string $filename): ?int
|
||
{
|
||
$fields = '<member><name>name</name><value><string>' . $this->xmlEsc($filename) . '</string></value></member>'
|
||
. '<member><name>type</name><value><string>' . $this->getMimeType($filename) . '</string></value></member>'
|
||
. '<member><name>bits</name><value><base64>' . base64_encode($imageData) . '</base64></value></member>';
|
||
|
||
$xml = $this->xmlRpcEnvelope('wp.uploadFile', $auth, '<param><value><struct>' . $fields . '</struct></value></param>');
|
||
|
||
$result = $this->sendXmlRpc($site['url'], $xml, 60);
|
||
if ($result === null) {
|
||
return null;
|
||
}
|
||
|
||
// wp.uploadFile returns a struct; parse for 'id' or 'attachment_id'.
|
||
if (is_array($result)) {
|
||
$id = (int) ($result['id'] ?? $result['attachment_id'] ?? 0);
|
||
if ($id > 0) {
|
||
Logger::info("WP XML-RPC uploadFile success for {$site['url']}: media_id={$id}", 'wordpress');
|
||
return $id;
|
||
}
|
||
}
|
||
|
||
Logger::error("WP XML-RPC uploadFile returned unexpected response for {$site['url']}.", 'wordpress');
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Send an XML-RPC request and return the parsed response value, or null on failure.
|
||
*/
|
||
private function sendXmlRpc(string $siteUrl, string $xml, int $timeout = 30): mixed
|
||
{
|
||
$url = rtrim($siteUrl, '/') . '/xmlrpc.php';
|
||
|
||
try {
|
||
$response = $this->client->request('POST', $url, [
|
||
'body' => $xml,
|
||
'headers' => ['Content-Type' => 'text/xml; charset=UTF-8'],
|
||
'timeout' => $timeout,
|
||
]);
|
||
|
||
$body = $response->getBody()->getContents();
|
||
return $this->parseXmlRpcResponse($body, $siteUrl);
|
||
} catch (GuzzleException $e) {
|
||
Logger::error("WP XML-RPC request failed for {$siteUrl}: " . $e->getMessage(), 'wordpress');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function xmlRpcEnvelope(string $method, array $auth, string $contentParam): string
|
||
{
|
||
return '<?xml version="1.0" encoding="UTF-8"?>'
|
||
. '<methodCall><methodName>' . $method . '</methodName><params>'
|
||
. '<param><value><i4>1</i4></value></param>'
|
||
. '<param><value><string>' . $this->xmlEsc($auth[0]) . '</string></value></param>'
|
||
. '<param><value><string>' . $this->xmlEsc($auth[1]) . '</string></value></param>'
|
||
. $contentParam
|
||
. '</params></methodCall>';
|
||
}
|
||
|
||
private function parseXmlRpcResponse(string $body, string $siteUrl): mixed
|
||
{
|
||
try {
|
||
$prev = libxml_use_internal_errors(true);
|
||
$doc = new \SimpleXMLElement($body);
|
||
libxml_use_internal_errors($prev);
|
||
} catch (\Throwable $e) {
|
||
Logger::error("WP XML-RPC invalid XML from {$siteUrl}: " . $e->getMessage(), 'wordpress');
|
||
return null;
|
||
}
|
||
|
||
// Check for fault.
|
||
if (isset($doc->fault)) {
|
||
$members = $doc->fault->value->struct->member ?? [];
|
||
$faultString = 'Unknown';
|
||
foreach ($members as $m) {
|
||
if ((string) $m->name === 'faultString') {
|
||
$faultString = (string) ($m->value->string ?? $m->value);
|
||
}
|
||
}
|
||
Logger::error("WP XML-RPC fault from {$siteUrl}: {$faultString}", 'wordpress');
|
||
return null;
|
||
}
|
||
|
||
// Extract return value.
|
||
$val = $doc->params->param->value ?? null;
|
||
if ($val === null) {
|
||
return null;
|
||
}
|
||
|
||
return $this->xmlRpcValue($val);
|
||
}
|
||
|
||
private function xmlRpcValue(\SimpleXMLElement $val): mixed
|
||
{
|
||
if (isset($val->string)) {
|
||
return (string) $val->string;
|
||
}
|
||
if (isset($val->int)) {
|
||
return (int) (string) $val->int;
|
||
}
|
||
if (isset($val->i4)) {
|
||
return (int) (string) $val->i4;
|
||
}
|
||
if (isset($val->boolean)) {
|
||
return (bool) (int) (string) $val->boolean;
|
||
}
|
||
if (isset($val->struct)) {
|
||
$result = [];
|
||
foreach ($val->struct->member as $m) {
|
||
$key = (string) $m->name;
|
||
$result[$key] = $this->xmlRpcValue($m->value);
|
||
}
|
||
return $result;
|
||
}
|
||
if (isset($val->array)) {
|
||
$result = [];
|
||
foreach ($val->array->data->value as $v) {
|
||
$result[] = $this->xmlRpcValue($v);
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
// Bare <value>text</value> — treat as string.
|
||
return trim((string) $val);
|
||
}
|
||
|
||
private function xmlEsc(string $str): string
|
||
{
|
||
return htmlspecialchars($str, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
// ── REST API transport layer ──────────────────────────────────────
|
||
|
||
private function requestWp(array $site, string $method, string $route, array $options = [])
|
||
{
|
||
try {
|
||
return $this->requestWpWithOptions($site, $method, $route, $options);
|
||
} catch (RequestException $e) {
|
||
$status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0;
|
||
$isRead = strtoupper($method) === 'GET';
|
||
$hasAuth = isset($options['auth']);
|
||
|
||
if ($isRead && $hasAuth && in_array($status, [401, 403], true)) {
|
||
$fallbackOptions = $options;
|
||
unset($fallbackOptions['auth']);
|
||
return $this->requestWpWithOptions($site, $method, $route, $fallbackOptions);
|
||
}
|
||
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
private function requestWpWithOptions(array $site, string $method, string $route, array $options = [])
|
||
{
|
||
// 1. Try standard /wp-json/ URL.
|
||
try {
|
||
return $this->client->request($method, $this->buildWpJsonUrl($site, $route), $options);
|
||
} catch (RequestException $e) {
|
||
$status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0;
|
||
if ($status !== 404) {
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
// 2. Try /index.php/wp-json/ (PATHINFO-based URL).
|
||
// Works on many hosts without pretty permalinks where /wp-json/ returns 404.
|
||
// Some hosts ignore PATH_INFO and return the homepage HTML – detect via Content-Type.
|
||
$pathInfoUrl = rtrim($site['url'], '/') . '/index.php/wp-json/' . ltrim($route, '/');
|
||
try {
|
||
$response = $this->client->request($method, $pathInfoUrl, $options);
|
||
$ct = $response->getHeaderLine('Content-Type');
|
||
if (stripos($ct, 'json') !== false) {
|
||
return $response;
|
||
}
|
||
// Non-JSON (HTML page) – host ignores PATH_INFO, try next fallback
|
||
} catch (RequestException $e) {
|
||
$status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0;
|
||
if ($status !== 404) {
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
// 3. Fall back to ?rest_route= parameter.
|
||
// Build URLs with literal slashes (avoid Guzzle encoding / as %2F).
|
||
$extraQuery = $options['query'] ?? [];
|
||
if (!is_array($extraQuery)) {
|
||
$extraQuery = [];
|
||
}
|
||
$fallbackOptions = $options;
|
||
unset($fallbackOptions['query']);
|
||
|
||
$rootUrl = $this->buildRestRouteUrl($site['url'], $route, $extraQuery, false);
|
||
$indexUrl = $this->buildRestRouteUrl($site['url'], $route, $extraQuery, true);
|
||
|
||
if (strtoupper($method) !== 'GET') {
|
||
// For writes, try /index.php?rest_route= first (no redirects).
|
||
// Some hosts return rest_post_exists on /?rest_route= because the WP
|
||
// main query resolves the homepage ID before the REST handler runs.
|
||
try {
|
||
$noRedirectOpts = $fallbackOptions;
|
||
$noRedirectOpts['allow_redirects'] = false;
|
||
$response = $this->client->request($method, $indexUrl, $noRedirectOpts);
|
||
$statusCode = $response->getStatusCode();
|
||
if ($statusCode < 300 && $this->isRestApiResponse($response)) {
|
||
return $response;
|
||
}
|
||
// Non-REST response or redirect – fall through to root URL
|
||
} catch (RequestException $e) {
|
||
// /index.php?rest_route= failed – fall through to root URL
|
||
}
|
||
|
||
return $this->client->request($method, $rootUrl, $fallbackOptions);
|
||
}
|
||
|
||
// For reads, try root URL first, then /index.php
|
||
try {
|
||
return $this->client->request($method, $rootUrl, $fallbackOptions);
|
||
} catch (RequestException $rootError) {
|
||
try {
|
||
return $this->client->request($method, $indexUrl, $fallbackOptions);
|
||
} catch (RequestException $e) {
|
||
throw $rootError;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a response looks like a WordPress REST API response (not a page render).
|
||
* Reads and rewinds the body stream for further consumption by the caller.
|
||
*/
|
||
private function isRestApiResponse($response): bool
|
||
{
|
||
$contentType = $response->getHeaderLine('Content-Type');
|
||
if (stripos($contentType, 'json') === false) {
|
||
return false;
|
||
}
|
||
|
||
$body = (string) $response->getBody();
|
||
$data = json_decode($body, true);
|
||
|
||
if ($response->getBody()->isSeekable()) {
|
||
$response->getBody()->rewind();
|
||
}
|
||
|
||
// A valid REST API write response has 'id'/'deleted', an error has 'code'+'message'.
|
||
// The REST API discovery/root response has 'namespaces' – that is NOT a write result.
|
||
if (!is_array($data)) {
|
||
return false;
|
||
}
|
||
if (isset($data['id'])
|
||
|| isset($data['deleted'])
|
||
|| isset($data['previous'])
|
||
|| (isset($data['code']) && isset($data['message']))
|
||
) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function buildWpJsonUrl(array $site, string $route): string
|
||
{
|
||
return rtrim($site['url'], '/') . '/wp-json/' . ltrim($route, '/');
|
||
}
|
||
|
||
private function buildAuthOption(array $site): ?array
|
||
{
|
||
$user = trim((string) ($site['api_user'] ?? ''));
|
||
$token = trim((string) ($site['api_token'] ?? ''));
|
||
$token = preg_replace('/\s+/', '', $token) ?? $token;
|
||
|
||
if ($user === '' || $token === '') {
|
||
return null;
|
||
}
|
||
|
||
return [$user, $token];
|
||
}
|
||
|
||
private function requireAuthOption(array $site, string $operation): ?array
|
||
{
|
||
$auth = $this->buildAuthOption($site);
|
||
if ($auth !== null) {
|
||
return $auth;
|
||
}
|
||
|
||
Logger::error(
|
||
"WP {$operation} skipped for {$site['url']}: missing api_user/api_token in site settings.",
|
||
'wordpress'
|
||
);
|
||
return null;
|
||
}
|
||
|
||
private function formatCommentSettings(array $data): array
|
||
{
|
||
$status = (string) ($data['default_comment_status'] ?? '');
|
||
$enabled = array_key_exists('comments_enabled', $data)
|
||
? (bool) $data['comments_enabled']
|
||
: $status === 'open';
|
||
|
||
return [
|
||
'success' => true,
|
||
'default_comment_status' => $status !== '' ? $status : ($enabled ? 'open' : 'closed'),
|
||
'comments_enabled' => $enabled,
|
||
'message' => (string) ($data['message'] ?? 'OK'),
|
||
];
|
||
}
|
||
|
||
private function buildCommentsQuery(string $status, int $page, int $perPage): array
|
||
{
|
||
$allowedStatuses = ['all', 'hold', 'approve', 'spam', 'trash'];
|
||
|
||
return [
|
||
'status' => in_array($status, $allowedStatuses, true) ? $status : 'all',
|
||
'page' => $page,
|
||
'per_page' => $perPage,
|
||
'orderby' => 'date',
|
||
'order' => 'desc',
|
||
'context' => 'edit',
|
||
];
|
||
}
|
||
|
||
private function buildCommentsSuccess($response, array $comments, int $page): array
|
||
{
|
||
return [
|
||
'success' => true,
|
||
'comments' => $comments,
|
||
'page' => $page,
|
||
'total_pages' => max(1, (int) $response->getHeaderLine('X-WP-TotalPages')),
|
||
'total' => max(0, (int) $response->getHeaderLine('X-WP-Total')),
|
||
'message' => 'OK',
|
||
];
|
||
}
|
||
|
||
private function buildCommentsFailure(int $page, string $message): array
|
||
{
|
||
return [
|
||
'success' => false,
|
||
'comments' => [],
|
||
'page' => $page,
|
||
'total_pages' => 1,
|
||
'total' => 0,
|
||
'message' => $message,
|
||
];
|
||
}
|
||
|
||
private function formatWpRequestError(RequestException $e, string $fallback): string
|
||
{
|
||
$statusCode = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0;
|
||
if (in_array($statusCode, [401, 403], true)) {
|
||
return 'Brak uprawnien WordPress API. Sprawdz uzytkownika i haslo aplikacyjne/API token.';
|
||
}
|
||
|
||
if ($statusCode === 404) {
|
||
return 'Zasob WordPress nie istnieje albo endpoint REST API jest niedostepny.';
|
||
}
|
||
|
||
if ($e->hasResponse()) {
|
||
$body = (string) $e->getResponse()->getBody();
|
||
$data = json_decode($body, true);
|
||
if (is_array($data) && !empty($data['message'])) {
|
||
return (string) $data['message'];
|
||
}
|
||
}
|
||
|
||
return $fallback . ' ' . $e->getMessage();
|
||
}
|
||
|
||
private function buildRestRouteUrl(string $siteUrl, string $route, array $extraQuery = [], bool $useIndexPhp = false): string
|
||
{
|
||
$base = rtrim($siteUrl, '/');
|
||
$base .= $useIndexPhp ? '/index.php' : '/';
|
||
|
||
// Use literal slashes in rest_route to avoid %2F encoding issues on some hosts.
|
||
$restRoute = '/' . ltrim($route, '/');
|
||
$parts = ['rest_route=' . $restRoute];
|
||
|
||
foreach ($extraQuery as $key => $value) {
|
||
$parts[] = urlencode((string) $key) . '=' . urlencode((string) $value);
|
||
}
|
||
|
||
return $base . '?' . implode('&', $parts);
|
||
}
|
||
|
||
private function getMimeType(string $filename): string
|
||
{
|
||
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
|
||
return match ($ext) {
|
||
'png' => 'image/png',
|
||
'gif' => 'image/gif',
|
||
'webp' => 'image/webp',
|
||
default => 'image/jpeg',
|
||
};
|
||
}
|
||
|
||
private function prepareRemoteServiceMetadata(array $site): array
|
||
{
|
||
$data = $site;
|
||
$token = trim((string) ($data['remote_service_token'] ?? ''));
|
||
$file = trim((string) ($data['remote_service_file'] ?? ''));
|
||
|
||
$changed = false;
|
||
if ($token === '') {
|
||
$base = (string) ($data['url'] ?? '') . '|' . (string) ($data['api_token'] ?? '') . '|backpro-remote-service';
|
||
$token = substr(hash('sha256', $base), 0, 48);
|
||
if ($token === '') {
|
||
$token = bin2hex(random_bytes(24));
|
||
}
|
||
$data['remote_service_token'] = $token;
|
||
$changed = true;
|
||
}
|
||
|
||
if ($file === '') {
|
||
$file = self::BACKPRO_REMOTE_SERVICE_FILENAME;
|
||
$data['remote_service_file'] = $file;
|
||
$changed = true;
|
||
}
|
||
|
||
if ($changed && !empty($data['id'])) {
|
||
try {
|
||
Site::update((int) $data['id'], [
|
||
'remote_service_token' => $token,
|
||
'remote_service_file' => $file,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
Logger::warning('Could not persist remote service metadata: ' . $e->getMessage(), 'wordpress');
|
||
}
|
||
}
|
||
|
||
return $data;
|
||
}
|
||
|
||
private function buildRemoteServiceUrl(array $site): string
|
||
{
|
||
$siteData = $this->prepareRemoteServiceMetadata($site);
|
||
$file = (string) $siteData['remote_service_file'];
|
||
return rtrim((string) $siteData['url'], '/') . '/' . ltrim($file, '/');
|
||
}
|
||
|
||
private function callRemoteService(array $site, string $action, array $params = []): array
|
||
{
|
||
$siteData = $this->prepareRemoteServiceMetadata($site);
|
||
$url = $this->buildRemoteServiceUrl($siteData);
|
||
$token = (string) $siteData['remote_service_token'];
|
||
|
||
if ($token === '') {
|
||
return ['success' => false, 'message' => 'Brak tokenu pliku serwisowego BackPRO.'];
|
||
}
|
||
|
||
try {
|
||
$response = $this->client->post($url, [
|
||
'timeout' => 20,
|
||
'headers' => ['User-Agent' => 'BackPRO/1.0 Remote-Service'],
|
||
'form_params' => array_merge([
|
||
'token' => $token,
|
||
'action' => $action,
|
||
], $params),
|
||
]);
|
||
|
||
$data = json_decode($response->getBody()->getContents(), true);
|
||
if (!is_array($data)) {
|
||
return ['success' => false, 'message' => 'Nieprawidlowa odpowiedz pliku serwisowego BackPRO.'];
|
||
}
|
||
|
||
return $data;
|
||
} catch (\Throwable $e) {
|
||
Logger::warning(
|
||
"Remote service call failed ({$action}) for {$siteData['url']}: " . $e->getMessage(),
|
||
'wordpress'
|
||
);
|
||
return ['success' => false, 'message' => 'Blad komunikacji z plikiem serwisowym: ' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
private function getBackproRemoteServiceContent(string $token): string
|
||
{
|
||
$safeToken = addslashes($token);
|
||
|
||
return <<<PHP
|
||
<?php
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
|
||
if (\$_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||
http_response_code(405);
|
||
echo json_encode(['success' => false, 'message' => 'method_not_allowed']);
|
||
exit;
|
||
}
|
||
|
||
\$expectedToken = '{$safeToken}';
|
||
\$providedToken = (string) (\$_POST['token'] ?? '');
|
||
if (!hash_equals(\$expectedToken, \$providedToken)) {
|
||
http_response_code(403);
|
||
echo json_encode(['success' => false, 'message' => 'forbidden']);
|
||
exit;
|
||
}
|
||
|
||
if (!defined('ABSPATH')) {
|
||
\$wpLoad = __DIR__ . '/wp-load.php';
|
||
if (!file_exists(\$wpLoad)) {
|
||
http_response_code(500);
|
||
echo json_encode(['success' => false, 'message' => 'wp_load_not_found']);
|
||
exit;
|
||
}
|
||
require_once \$wpLoad;
|
||
}
|
||
|
||
\$action = (string) (\$_POST['action'] ?? '');
|
||
if (\$action === 'ping') {
|
||
echo json_encode(['success' => true, 'message' => 'pong', 'version' => '1.5.0']);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'get_permalink') {
|
||
\$structure = (string) get_option('permalink_structure', '');
|
||
echo json_encode([
|
||
'success' => true,
|
||
'permalink_structure' => \$structure,
|
||
'pretty_enabled' => trim(\$structure) !== '',
|
||
'message' => 'OK',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'set_permalink') {
|
||
\$structure = trim((string) (\$_POST['structure'] ?? ''));
|
||
if (\$structure === '') {
|
||
http_response_code(422);
|
||
echo json_encode(['success' => false, 'message' => 'missing_structure']);
|
||
exit;
|
||
}
|
||
|
||
update_option('permalink_structure', \$structure);
|
||
// Hard flush rewrites to update .htaccess when possible.
|
||
flush_rewrite_rules(true);
|
||
\$applied = (string) get_option('permalink_structure', '');
|
||
|
||
echo json_encode([
|
||
'success' => true,
|
||
'permalink_structure' => \$applied,
|
||
'pretty_enabled' => trim(\$applied) !== '',
|
||
'message' => 'Permalinki zaktualizowane.',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'flush_rewrite') {
|
||
flush_rewrite_rules(true);
|
||
echo json_encode([
|
||
'success' => true,
|
||
'message' => 'Rewrite rules odswiezone.',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'activate_theme') {
|
||
\$stylesheet = sanitize_key((string) (\$_POST['stylesheet'] ?? ''));
|
||
if (\$stylesheet === '') {
|
||
http_response_code(422);
|
||
echo json_encode(['success' => false, 'message' => 'missing_stylesheet']);
|
||
exit;
|
||
}
|
||
|
||
\$theme = wp_get_theme(\$stylesheet);
|
||
if (!\$theme->exists()) {
|
||
http_response_code(404);
|
||
echo json_encode(['success' => false, 'message' => 'theme_not_found']);
|
||
exit;
|
||
}
|
||
|
||
switch_theme(\$stylesheet);
|
||
echo json_encode([
|
||
'success' => true,
|
||
'message' => 'Motyw aktywowany.',
|
||
'active_stylesheet' => get_stylesheet(),
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'set_blog_public') {
|
||
\$blogPublicRaw = (string) (\$_POST['blog_public'] ?? '1');
|
||
\$blogPublic = \$blogPublicRaw === '0' ? 0 : 1;
|
||
|
||
update_option('blog_public', \$blogPublic);
|
||
\$applied = (int) get_option('blog_public', 1);
|
||
|
||
echo json_encode([
|
||
'success' => true,
|
||
'blog_public' => \$applied,
|
||
'message' => \$applied === 1
|
||
? 'Indeksowanie przez wyszukiwarki jest wlaczone.'
|
||
: 'Indeksowanie przez wyszukiwarki jest wylaczone.',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'get_comment_settings') {
|
||
\$status = (string) get_option('default_comment_status', 'open');
|
||
echo json_encode([
|
||
'success' => true,
|
||
'default_comment_status' => \$status,
|
||
'comments_enabled' => \$status === 'open',
|
||
'message' => 'OK',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'set_comment_settings') {
|
||
\$enabledRaw = (string) (\$_POST['comments_enabled'] ?? '');
|
||
if (\$enabledRaw !== '0' && \$enabledRaw !== '1') {
|
||
http_response_code(422);
|
||
echo json_encode(['success' => false, 'message' => 'missing_comments_enabled']);
|
||
exit;
|
||
}
|
||
|
||
\$status = \$enabledRaw === '1' ? 'open' : 'closed';
|
||
update_option('default_comment_status', \$status);
|
||
\$applied = (string) get_option('default_comment_status', 'open');
|
||
|
||
echo json_encode([
|
||
'success' => true,
|
||
'default_comment_status' => \$applied,
|
||
'comments_enabled' => \$applied === 'open',
|
||
'message' => \$applied === 'open'
|
||
? 'Komentowanie nowych wpisow jest wlaczone.'
|
||
: 'Komentowanie nowych wpisow jest wylaczone.',
|
||
]);
|
||
exit;
|
||
}
|
||
|
||
if (\$action === 'cleanup') {
|
||
@unlink(__FILE__);
|
||
echo json_encode(['success' => true, 'message' => 'service_deleted']);
|
||
exit;
|
||
}
|
||
|
||
http_response_code(400);
|
||
echo json_encode(['success' => false, 'message' => 'invalid_action']);
|
||
PHP;
|
||
}
|
||
|
||
private function ensureHtaccessForPrettyPermalinks(array $site): void
|
||
{
|
||
$ftpHost = trim((string) ($site['ftp_host'] ?? ''));
|
||
$ftpUser = trim((string) ($site['ftp_user'] ?? ''));
|
||
$ftpPass = (string) ($site['ftp_pass'] ?? '');
|
||
$ftpPort = (int) ($site['ftp_port'] ?? 21);
|
||
|
||
if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') {
|
||
return;
|
||
}
|
||
|
||
$ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21);
|
||
$tmpIn = tempnam(sys_get_temp_dir(), 'backpro_hta_in_');
|
||
$tmpOut = tempnam(sys_get_temp_dir(), 'backpro_hta_out_');
|
||
if ($tmpIn === false || $tmpOut === false) {
|
||
return;
|
||
}
|
||
|
||
$basePath = trim((string) ($site['ftp_path'] ?? ''), "/ \t\n\r\0\x0B");
|
||
$remoteDir = ($basePath !== '' ? '/' . $basePath : '');
|
||
$remoteHtaccess = rtrim($remoteDir, '/') . '/.htaccess';
|
||
|
||
try {
|
||
$ftp->connect();
|
||
$existing = '';
|
||
if ($ftp->downloadFile($remoteHtaccess, $tmpIn) && is_file($tmpIn)) {
|
||
$existing = (string) file_get_contents($tmpIn);
|
||
}
|
||
|
||
$updated = $this->injectWordPressHtaccessBlock($existing);
|
||
file_put_contents($tmpOut, $updated);
|
||
$ftp->uploadFile($tmpOut, $remoteHtaccess);
|
||
|
||
Logger::info("Ensured .htaccess WordPress rewrite rules for {$site['url']}", 'wordpress');
|
||
} catch (\Throwable $e) {
|
||
Logger::warning("Could not update .htaccess for {$site['url']}: " . $e->getMessage(), 'wordpress');
|
||
} finally {
|
||
$ftp->disconnect();
|
||
@unlink($tmpIn);
|
||
@unlink($tmpOut);
|
||
}
|
||
}
|
||
|
||
private function injectWordPressHtaccessBlock(string $content): string
|
||
{
|
||
$block = "# BEGIN WordPress\n"
|
||
. "<IfModule mod_rewrite.c>\n"
|
||
. "RewriteEngine On\n"
|
||
. "RewriteBase /\n"
|
||
. "RewriteRule ^index\\.php$ - [L]\n"
|
||
. "RewriteCond %{REQUEST_FILENAME} !-f\n"
|
||
. "RewriteCond %{REQUEST_FILENAME} !-d\n"
|
||
. "RewriteRule . /index.php [L]\n"
|
||
. "</IfModule>\n"
|
||
. "# END WordPress";
|
||
|
||
$trimmed = trim($content);
|
||
if ($trimmed === '') {
|
||
return $block . "\n";
|
||
}
|
||
|
||
$pattern = '/# BEGIN WordPress.*?# END WordPress/s';
|
||
if (preg_match($pattern, $content) === 1) {
|
||
return (string) preg_replace($pattern, $block, $content);
|
||
}
|
||
|
||
return rtrim($content) . "\n\n" . $block . "\n";
|
||
}
|
||
|
||
private function getBackproMuPluginContent(): string
|
||
{
|
||
return <<<'PHP'
|
||
<?php
|
||
/**
|
||
* Plugin Name: BackPRO Remote Tools (MU)
|
||
* Description: Remote management tools for BackPRO.
|
||
* Version: 1.0.0
|
||
*/
|
||
|
||
if (!defined('ABSPATH')) {
|
||
exit;
|
||
}
|
||
|
||
add_action('rest_api_init', function (): void {
|
||
register_rest_route('backpro/v1', '/permalinks', [
|
||
'methods' => 'GET',
|
||
'callback' => function (): \WP_REST_Response {
|
||
$structure = (string) get_option('permalink_structure', '');
|
||
$pretty = trim($structure) !== '';
|
||
|
||
return new \WP_REST_Response([
|
||
'success' => true,
|
||
'source' => 'backpro_mu_plugin',
|
||
'permalink_structure' => $structure,
|
||
'pretty_enabled' => $pretty,
|
||
'message' => $pretty ? 'Przyjazne linki sa wlaczone.' : 'Przyjazne linki sa wylaczone.',
|
||
], 200);
|
||
},
|
||
'permission_callback' => function (): bool {
|
||
return current_user_can('manage_options');
|
||
},
|
||
]);
|
||
|
||
register_rest_route('backpro/v1', '/permalinks', [
|
||
'methods' => 'POST',
|
||
'callback' => function (\WP_REST_Request $request): \WP_REST_Response {
|
||
$structure = (string) $request->get_param('structure');
|
||
$structure = trim($structure);
|
||
|
||
if ($structure === '') {
|
||
return new \WP_REST_Response([
|
||
'success' => false,
|
||
'message' => 'Brak struktury permalink.',
|
||
], 400);
|
||
}
|
||
|
||
update_option('permalink_structure', $structure);
|
||
flush_rewrite_rules(false);
|
||
|
||
return new \WP_REST_Response([
|
||
'success' => true,
|
||
'permalink_structure' => (string) get_option('permalink_structure', ''),
|
||
'pretty_enabled' => true,
|
||
'message' => 'Zmieniono ustawienie permalink i odswiezono rewrite rules.',
|
||
], 200);
|
||
},
|
||
'permission_callback' => function (): bool {
|
||
return current_user_can('manage_options');
|
||
},
|
||
]);
|
||
});
|
||
PHP;
|
||
}
|
||
}
|