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;
}
}