Files
backPRO/src/Services/WordPressService.php
2026-04-24 09:33:20 +02:00

1611 lines
59 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}