Ansico-plugins/ansico-plugins/includes/class-ansico-plugins-client.php
2026-04-18 23:19:08 +02:00

627 lines
24 KiB
PHP

<?php
if (!defined('ABSPATH')) {
exit;
}
class Ansico_Plugins_Client {
private $settings;
public function __construct($settings = array()) {
$this->settings = empty($settings) ? Ansico_Plugins::get_settings() : $settings;
}
private function base_url() {
return trailingslashit((string) $this->settings['forgejo_base_url']);
}
private function owner_name() {
return trim((string) $this->settings['forgejo_owner']);
}
private function owner_type() {
$owner_type = isset($this->settings['owner_type']) ? strtolower((string) $this->settings['owner_type']) : 'user';
return in_array($owner_type, array('org', 'organisation', 'organization'), true) ? 'org' : 'user';
}
private function cache_key($method, $url) {
return 'ansico_plugins_' . md5(strtoupper((string) $method) . '|' . $url . '|' . wp_json_encode(array(
'token' => !empty($this->settings['access_token']) ? 1 : 0,
'ssl' => !empty($this->settings['verify_ssl']) ? 1 : 0,
)));
}
private function normalize_http_response($response) {
return array(
'code' => (int) wp_remote_retrieve_response_code($response),
'message' => (string) wp_remote_retrieve_response_message($response),
'body' => (string) wp_remote_retrieve_body($response),
'headers' => array(),
);
}
private function parse_json_response($response, $error_code, $error_message) {
if (is_wp_error($response)) {
return $response;
}
$body = isset($response['body']) ? (string) $response['body'] : '';
$data = json_decode($body, true);
if (JSON_ERROR_NONE !== json_last_error()) {
return new WP_Error($error_code, $error_message . ' JSON error: ' . json_last_error_msg());
}
return $data;
}
private function get_owner_endpoint() {
$base = $this->base_url();
$owner = rawurlencode($this->owner_name());
if ('org' === $this->owner_type()) {
return sprintf('%sapi/v1/orgs/%s/repos?limit=100', $base, $owner);
}
return sprintf('%sapi/v1/users/%s/repos?limit=100', $base, $owner);
}
public function test_connection() {
$base = $this->base_url();
if ('' === $base) {
return new WP_Error('missing_base_url', __('Forgejo base URL mangler.', 'ansico-plugins'));
}
$response = $this->request('GET', $base . 'api/v1/version', false);
if (is_wp_error($response)) {
return $response;
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('bad_response', sprintf(__('Forgejo svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$body = $this->parse_json_response($response, 'invalid_version', __('Kunne ikke læse version fra Forgejo.', 'ansico-plugins'));
if (is_wp_error($body)) {
return $body;
}
return array(
'version' => isset($body['version']) ? (string) $body['version'] : __('Ukendt', 'ansico-plugins'),
);
}
public function get_repositories_without_filter() {
if ('' === $this->base_url() || '' === $this->owner_name()) {
return new WP_Error('missing_settings', __('Du skal udfylde Forgejo URL og owner/org først.', 'ansico-plugins'));
}
$response = $this->request('GET', $this->get_owner_endpoint());
if (is_wp_error($response)) {
return $response;
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('repos_http_error', sprintf(__('Repository-endpoint svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$repos = $this->parse_json_response($response, 'invalid_repos', __('Kunne ikke læse repositories fra Forgejo.', 'ansico-plugins'));
if (is_wp_error($repos)) {
return $repos;
}
return is_array($repos) ? $repos : array();
}
public function get_repositories() {
$all_repos = $this->get_repositories_without_filter();
if (is_wp_error($all_repos)) {
return $all_repos;
}
$topic_filter = strtolower(trim((string) $this->settings['topic_filter']));
$filtered = array();
foreach ($all_repos as $repo) {
if (empty($repo['name']) || empty($repo['owner']['login'])) {
continue;
}
$topics = $this->get_repository_topics_safe($repo);
$repo['topics'] = $topics;
$normalized_topics = array_map('strtolower', array_map('strval', (array) $topics));
if ('' === $topic_filter || in_array($topic_filter, $normalized_topics, true)) {
$filtered[] = $repo;
}
}
return $filtered;
}
public function get_repositories_debug() {
$all_repos = $this->get_repositories_without_filter();
if (is_wp_error($all_repos)) {
return $all_repos;
}
$topic_filter = strtolower(trim((string) $this->settings['topic_filter']));
$rows = array();
foreach ($all_repos as $repo) {
$owner = !empty($repo['owner']['login']) ? (string) $repo['owner']['login'] : '';
$name = !empty($repo['name']) ? (string) $repo['name'] : '';
$topics_result = ($owner !== '' && $name !== '') ? $this->get_repository_topics($owner, $name) : new WP_Error('missing_repo_data', __('Repo mangler owner eller navn.', 'ansico-plugins'));
$topics = is_wp_error($topics_result) ? array() : (array) $topics_result;
$release = ($owner !== '' && $name !== '') ? $this->get_latest_release($owner, $name) : new WP_Error('missing_repo_data', __('Repo mangler owner eller navn.', 'ansico-plugins'));
$rows[] = array(
'name' => $name,
'full_name' => !empty($repo['full_name']) ? (string) $repo['full_name'] : $name,
'topics' => $topics,
'matches_filter' => ('' === $topic_filter || in_array($topic_filter, array_map('strtolower', array_map('strval', $topics)), true)),
'release' => is_wp_error($release) ? '' : (!empty($release['tag_name']) ? (string) $release['tag_name'] : (!empty($release['name']) ? (string) $release['name'] : '')),
'topics_error' => is_wp_error($topics_result) ? $topics_result->get_error_message() : '',
'release_error' => is_wp_error($release) ? $release->get_error_message() : '',
);
}
return $rows;
}
private function get_repository_topics_safe($repo) {
if (!empty($repo['topics']) && is_array($repo['topics'])) {
return $repo['topics'];
}
$owner = !empty($repo['owner']['login']) ? (string) $repo['owner']['login'] : '';
$name = !empty($repo['name']) ? (string) $repo['name'] : '';
if ('' === $owner || '' === $name) {
return array();
}
$topics = $this->get_repository_topics($owner, $name);
if (!is_wp_error($topics)) {
return (array) $topics;
}
$repo_details = $this->get_repository($owner, $name);
if (!is_wp_error($repo_details) && !empty($repo_details['topics']) && is_array($repo_details['topics'])) {
return $repo_details['topics'];
}
return array();
}
public function get_repository($owner, $repo_name) {
$endpoint = sprintf('%sapi/v1/repos/%s/%s', $this->base_url(), rawurlencode($owner), rawurlencode($repo_name));
$response = $this->request('GET', $endpoint);
if (is_wp_error($response)) {
return $response;
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('repo_http_error', sprintf(__('Repository-endpoint svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$repo = $this->parse_json_response($response, 'invalid_repo', __('Kunne ikke læse repository-data.', 'ansico-plugins'));
return is_wp_error($repo) ? $repo : (is_array($repo) ? $repo : array());
}
public function get_repository_topics($owner, $repo_name) {
$endpoint = sprintf('%sapi/v1/repos/%s/%s/topics', $this->base_url(), rawurlencode($owner), rawurlencode($repo_name));
$response = $this->request('GET', $endpoint);
if (is_wp_error($response)) {
return $response;
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('topics_http_error', sprintf(__('Topics-endpoint svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$body = $this->parse_json_response($response, 'invalid_topics', __('Kunne ikke læse repository-topics.', 'ansico-plugins'));
if (is_wp_error($body)) {
return $body;
}
if (isset($body['topics']) && is_array($body['topics'])) {
return $body['topics'];
}
return is_array($body) ? $body : array();
}
public function get_latest_release($owner, $repo_name) {
$endpoint = sprintf('%sapi/v1/repos/%s/%s/releases/latest', $this->base_url(), rawurlencode($owner), rawurlencode($repo_name));
$response = $this->request('GET', $endpoint);
if (is_wp_error($response)) {
return $response;
}
if (404 === (int) $response['code']) {
return new WP_Error('release_not_found', __('Ingen release fundet.', 'ansico-plugins'));
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('release_http_error', sprintf(__('Release-endpoint svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$release = $this->parse_json_response($response, 'invalid_release', __('Kunne ikke læse release-data.', 'ansico-plugins'));
return is_wp_error($release) ? $release : (is_array($release) ? $release : array());
}
public function find_zip_asset($release) {
if (empty($release['assets']) || !is_array($release['assets'])) {
return null;
}
foreach ($release['assets'] as $asset) {
$name = isset($asset['name']) ? (string) $asset['name'] : '';
if (preg_match('/\.zip$/i', $name)) {
return $asset;
}
}
if (!empty($release['zipball_url'])) {
return array(
'name' => basename((string) $release['zipball_url']),
'browser_download_url' => (string) $release['zipball_url'],
);
}
return null;
}
public function get_repository_contents($owner, $repo_name, $path = '', $ref = '') {
$endpoint = sprintf('%sapi/v1/repos/%s/%s/contents', $this->base_url(), rawurlencode($owner), rawurlencode($repo_name));
$path = ltrim((string) $path, '/');
if ('' !== $path) {
$endpoint .= '/' . str_replace('%2F', '/', rawurlencode($path));
}
$query = array();
if ('' !== (string) $ref) {
$query['ref'] = (string) $ref;
}
if (!empty($query)) {
$endpoint .= '?' . http_build_query($query, '', '&');
}
$response = $this->request('GET', $endpoint);
if (is_wp_error($response)) {
return $response;
}
if (404 === (int) $response['code']) {
return new WP_Error('contents_not_found', __('Fil eller mappe blev ikke fundet i repository.', 'ansico-plugins'));
}
if ($response['code'] < 200 || $response['code'] >= 300) {
return new WP_Error('contents_http_error', sprintf(__('Contents-endpoint svarede med HTTP %d.', 'ansico-plugins'), (int) $response['code']));
}
$contents = $this->parse_json_response($response, 'invalid_contents', __('Kunne ikke læse repository-indhold.', 'ansico-plugins'));
return is_wp_error($contents) ? $contents : $contents;
}
public function get_repository_readme_title($owner, $repo_name, $ref = '') {
$documentation = $this->get_repository_documentation($owner, $repo_name, $ref);
if (is_wp_error($documentation)) {
return '';
}
return !empty($documentation['title']) ? (string) $documentation['title'] : '';
}
public function get_repository_documentation($owner, $repo_name, $ref = '') {
$candidates = array('readme.txt', 'README.txt', 'README.md', 'readme.md', 'Readme.md');
foreach ($candidates as $candidate) {
$raw = $this->get_repository_text_file($owner, $repo_name, $candidate, $ref);
if ('' === $raw) {
continue;
}
if (preg_match('/\.txt$/i', $candidate)) {
$parsed = $this->parse_wordpress_readme($raw);
} else {
$parsed = $this->parse_markdown_readme($raw);
}
if (!empty($parsed['title']) || !empty($parsed['short_description']) || !empty($parsed['sections'])) {
$parsed['source'] = $candidate;
return $parsed;
}
}
return array(
'title' => '',
'short_description' => '',
'sections' => array(),
'source' => '',
);
}
private function get_repository_text_file($owner, $repo_name, $path, $ref = '') {
$file = $this->get_repository_contents($owner, $repo_name, $path, $ref);
if (is_wp_error($file) || !is_array($file) || empty($file['content']) || empty($file['encoding']) || 'base64' !== $file['encoding']) {
return '';
}
$raw = base64_decode((string) $file['content'], true);
if (false === $raw) {
return '';
}
return str_replace(array("
", "
"), "
", (string) $raw);
}
private function parse_wordpress_readme($raw) {
$raw = trim((string) $raw);
$lines = preg_split('/
/', $raw);
$title = '';
$short_description = '';
$sections = array();
$current = '';
$buffer = array();
$header_done = false;
foreach ($lines as $line) {
if ('' === $title && preg_match('/^===\s*(.+?)\s*===\s*$/', $line, $m)) {
$title = trim($m[1]);
continue;
}
if (!$header_done && preg_match('/^==\s*(.+?)\s*==\s*$/', $line, $m)) {
$header_done = true;
$current = strtolower(trim($m[1]));
$buffer = array();
continue;
}
if (!$header_done) {
if ('' === $short_description && '' !== trim($line) && false === strpos($line, ':')) {
$short_description = trim($line);
}
continue;
}
if (preg_match('/^==\s*(.+?)\s*==\s*$/', $line, $m)) {
if ('' !== $current) {
$sections[$current] = trim(implode("
", $buffer));
}
$current = strtolower(trim($m[1]));
$buffer = array();
continue;
}
$buffer[] = $line;
}
if ('' !== $current) {
$sections[$current] = trim(implode("
", $buffer));
}
if ('' === $short_description && !empty($sections['description'])) {
$short_description = $this->first_paragraph($sections['description']);
}
return array(
'title' => $title,
'short_description' => $short_description,
'sections' => $sections,
);
}
private function parse_markdown_readme($raw) {
$title = '';
$short_description = '';
$sections = array();
$current = '';
$buffer = array();
$after_title = '';
foreach (preg_split('/
/', (string) $raw) as $line) {
if ('' === $title && preg_match('/^#\s+(.+)$/', $line, $m)) {
$title = trim(wp_strip_all_tags($m[1]));
continue;
}
if (preg_match('/^##\s+(.+)$/', $line, $m)) {
if ('' !== $current) {
$sections[$current] = trim(implode("
", $buffer));
}
$current = strtolower(trim(wp_strip_all_tags($m[1])));
$buffer = array();
continue;
}
if ('' === $current) {
$after_title .= $line . "
";
} else {
$buffer[] = $line;
}
}
if ('' !== $current) {
$sections[$current] = trim(implode("
", $buffer));
}
$short_description = $this->first_paragraph($after_title);
if ('' === $short_description && !empty($sections['description'])) {
$short_description = $this->first_paragraph($sections['description']);
}
return array(
'title' => $title,
'short_description' => $short_description,
'sections' => $sections,
);
}
private function first_paragraph($text) {
$text = trim((string) $text);
if ('' === $text) {
return '';
}
$parts = preg_split("/
\s*
/", $text);
if (empty($parts[0])) {
return '';
}
return trim(wp_strip_all_tags($parts[0]));
}
private function find_repository_asset_url($owner, $repo_name, $default_branch, $candidates) {
foreach ((array) $candidates as $candidate) {
$file = $this->get_repository_contents($owner, $repo_name, $candidate, $default_branch);
if (!is_wp_error($file) && is_array($file) && !empty($file['download_url'])) {
return (string) $file['download_url'];
}
}
return '';
}
public function get_repository_plugin_metadata($owner, $repo_name) {
$repo = $this->get_repository($owner, $repo_name);
if (is_wp_error($repo)) {
return $repo;
}
$default_branch = !empty($repo['default_branch']) ? (string) $repo['default_branch'] : '';
$root = $this->get_repository_contents($owner, $repo_name, '', $default_branch);
$plugin_headers = array();
if (!is_wp_error($root) && is_array($root)) {
foreach ($root as $item) {
if (!is_array($item) || empty($item['name'])) {
continue;
}
$name = (string) $item['name'];
if (empty($plugin_headers) && !empty($item['type']) && 'file' === $item['type'] && preg_match('/\.php$/i', $name)) {
$file = $this->get_repository_contents($owner, $repo_name, $name, $default_branch);
if (!is_wp_error($file) && is_array($file) && !empty($file['content']) && !empty($file['encoding']) && 'base64' === $file['encoding']) {
$raw = base64_decode((string) $file['content'], true);
if (false !== $raw) {
$plugin_headers = $this->parse_plugin_headers($raw);
}
}
}
}
}
$documentation = $this->get_repository_documentation($owner, $repo_name, $default_branch);
if (is_wp_error($documentation)) {
$documentation = array('title' => '', 'short_description' => '', 'sections' => array(), 'source' => '');
}
$author_name = !empty($plugin_headers['Author']) ? (string) $plugin_headers['Author'] : (!empty($repo['owner']['full_name']) ? (string) $repo['owner']['full_name'] : (!empty($repo['owner']['login']) ? (string) $repo['owner']['login'] : ''));
$author_url = !empty($plugin_headers['AuthorURI']) ? (string) $plugin_headers['AuthorURI'] : (!empty($repo['owner']['website']) ? (string) $repo['owner']['website'] : (!empty($repo['owner']['html_url']) ? (string) $repo['owner']['html_url'] : ''));
$support_url = !empty($plugin_headers['SupportURI']) ? (string) $plugin_headers['SupportURI'] : (!empty($repo['html_url']) ? trailingslashit((string) $repo['html_url']) . 'issues' : '');
$license = !empty($plugin_headers['License']) ? (string) $plugin_headers['License'] : (!empty($repo['license']['spdx_id']) ? (string) $repo['license']['spdx_id'] : '');
$screenshot_url = $this->find_repository_asset_url($owner, $repo_name, $default_branch, array('screenshot.png', 'assets/screenshot.png'));
$icon_url = $this->find_repository_asset_url($owner, $repo_name, $default_branch, array('icon-256x256.png', 'icon-128x128.png', 'assets/icon-256x256.png', 'assets/icon-128x128.png'));
$banner_url = $this->find_repository_asset_url($owner, $repo_name, $default_branch, array('banner-1544x500.png', 'banner-772x250.png', 'assets/banner-1544x500.png', 'assets/banner-772x250.png'));
return array(
'readme_title' => !empty($documentation['title']) ? $documentation['title'] : '',
'description' => !empty($documentation['short_description']) ? $documentation['short_description'] : (!empty($repo['description']) ? (string) $repo['description'] : ''),
'readme_sections' => !empty($documentation['sections']) && is_array($documentation['sections']) ? $documentation['sections'] : array(),
'readme_source' => !empty($documentation['source']) ? $documentation['source'] : '',
'author_name' => $author_name,
'author_url' => $author_url,
'plugin_headers' => $plugin_headers,
'support_url' => $support_url,
'license' => $license,
'screenshot_url' => $screenshot_url,
'icon_url' => $icon_url,
'banner_url' => $banner_url,
'default_branch' => $default_branch,
);
}
private function parse_plugin_headers($content) {
$headers = array(
'Plugin Name' => 'Name',
'Author' => 'Author',
'Author URI' => 'AuthorURI',
'Plugin URI' => 'PluginURI',
'Description' => 'Description',
'Version' => 'Version',
'Requires at least' => 'RequiresWP',
'Requires PHP' => 'RequiresPHP',
'License' => 'License',
'License URI' => 'LicenseURI',
'Text Domain' => 'TextDomain',
'Support URI' => 'SupportURI',
);
$parsed = array();
foreach ($headers as $label => $key) {
if (preg_match('/^[ \t\/*#@]*' . preg_quote($label, '/') . ':(.*)$/mi', (string) $content, $matches)) {
$parsed[$key] = trim(wp_strip_all_tags($matches[1]));
}
}
return $parsed;
}
public function request($method, $url, $allow_retry_without_auth = true) {
$method = strtoupper((string) $method);
$headers = array(
'Accept' => 'application/json',
'User-Agent' => 'Ansico-Plugins/' . ANSICO_PLUGINS_VERSION . '; ' . home_url('/'),
);
if (!empty($this->settings['access_token'])) {
$headers['Authorization'] = 'token ' . trim((string) $this->settings['access_token']);
}
$args = array(
'method' => $method,
'headers' => $headers,
'timeout' => max(5, (int) $this->settings['request_timeout']),
'sslverify' => !empty($this->settings['verify_ssl']),
);
$cache_key = $this->cache_key($method, $url);
if ('GET' === $method) {
$cached = get_transient($cache_key);
if (is_array($cached) && isset($cached['body'], $cached['code'])) {
return $cached;
}
}
$response = wp_remote_request(esc_url_raw($url), $args);
if (is_wp_error($response)) {
return $response;
}
$normalized = $this->normalize_http_response($response);
if (
$allow_retry_without_auth &&
!empty($this->settings['access_token']) &&
in_array((int) $normalized['code'], array(401, 403), true)
) {
unset($args['headers']['Authorization']);
$retry_response = wp_remote_request(esc_url_raw($url), $args);
if (!is_wp_error($retry_response)) {
$normalized = $this->normalize_http_response($retry_response);
}
}
if ('GET' === $method) {
set_transient($cache_key, $normalized, 5 * MINUTE_IN_SECONDS);
}
return $normalized;
}
}