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 = 'post_title' . $this->xmlEsc($title) . '' . 'post_content' . $this->xmlEsc($content) . '' . 'post_statuspublish'; if (is_string($excerpt) && trim($excerpt) !== '') { $fields .= 'mt_excerpt' . $this->xmlEsc(trim($excerpt)) . ''; } if ($categoryId) { $fields .= 'terms' . 'category' . '' . (int) $categoryId . '' . '' . ''; } if ($mediaId) { $fields .= 'post_thumbnail' . (int) $mediaId . ''; } $xml = $this->xmlRpcEnvelope('wp.newPost', $auth, '' . $fields . ''); $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 = 'name' . $this->xmlEsc($filename) . '' . 'type' . $this->getMimeType($filename) . '' . 'bits' . base64_encode($imageData) . ''; $xml = $this->xmlRpcEnvelope('wp.uploadFile', $auth, '' . $fields . ''); $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 '' . '' . $method . '' . '1' . '' . $this->xmlEsc($auth[0]) . '' . '' . $this->xmlEsc($auth[1]) . '' . $contentParam . ''; } 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 text — 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 << 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" . "\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" . "\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' '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; } }