1703a09319
- Render the Beep wordmark bottom-right of every beep AND reply, with the delete button furthest right (was: wordmark right of delete, missing on replies, and duplicated after the replies block). - Draw the wordmark as crisp inline SVG via beep_post_wordmark() (filterable); the bundled beep-wordmark.png assets are corrupted. Readable on dark theme. - Restore class-updater.php (self-hosted Gitea updates) deleted in the master->main consolidation; re-wire it in beep.php. - Remove stray includes/class-shortcode.php.bak. - Bump to 1.7.1; sync readme.txt stable tag + changelog. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
11 KiB
PHP
310 lines
11 KiB
PHP
<?php
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Self-hosted plugin updater backed by the Gitea releases API.
|
|
*
|
|
* Makes Beep update like a normal WordPress plugin: the "update available"
|
|
* badge appears in Plugins → Updates and one-click update works, pulling the
|
|
* latest published Release from the Gitea repo.
|
|
*
|
|
* Configuration (define in wp-config.php):
|
|
* define('BEEP_UPDATE_TOKEN', 'xxxxxxxx'); // required for a private repo
|
|
* define('BEEP_UPDATE_GITEA_BASE', 'https://git.sami'); // optional override
|
|
* define('BEEP_UPDATE_REPO', 'sami7777/beep'); // optional override
|
|
*
|
|
* To publish an update: bump the Version header in beep.php, push, then create
|
|
* a Release on Gitea whose tag is the new version (e.g. "v1.7.0" or "1.7.0").
|
|
* Optionally attach a "beep.zip" asset; otherwise the source archive is used.
|
|
*/
|
|
class Beep_Updater {
|
|
|
|
const TRANSIENT = 'beep_update_release';
|
|
const CACHE_HOURS = 12;
|
|
|
|
/** @var string e.g. "beep/beep.php" */
|
|
protected $plugin_file;
|
|
/** @var string e.g. "beep" */
|
|
protected $plugin_slug;
|
|
/** @var string installed version */
|
|
protected $version;
|
|
/** @var string Gitea base URL, no trailing slash */
|
|
protected $api_base;
|
|
/** @var string "owner/repo" */
|
|
protected $repo;
|
|
|
|
public static function init() {
|
|
$self = new self();
|
|
add_filter('pre_set_site_transient_update_plugins', [$self, 'check_for_update']);
|
|
add_filter('plugins_api', [$self, 'plugin_info'], 20, 3);
|
|
add_filter('http_request_args', [$self, 'authorize_request'], 10, 2);
|
|
add_filter('upgrader_source_selection', [$self, 'fix_source_dir'], 10, 4);
|
|
add_action('upgrader_process_complete', [$self, 'clear_cache'], 10, 2);
|
|
add_filter('plugin_action_links_' . $self->plugin_file, [$self, 'action_links']);
|
|
}
|
|
|
|
public function __construct() {
|
|
$this->plugin_file = plugin_basename(BEEP_FILE);
|
|
$this->plugin_slug = dirname($this->plugin_file);
|
|
if ($this->plugin_slug === '.' || $this->plugin_slug === '') {
|
|
$this->plugin_slug = 'beep';
|
|
}
|
|
$this->version = BEEP_VERSION;
|
|
$base = defined('BEEP_UPDATE_GITEA_BASE') ? BEEP_UPDATE_GITEA_BASE : 'https://git.sami';
|
|
$this->api_base = rtrim($base, '/');
|
|
$this->repo = defined('BEEP_UPDATE_REPO') ? BEEP_UPDATE_REPO : 'sami7777/beep';
|
|
}
|
|
|
|
/** Resolve the access token (constant first, then filter). */
|
|
protected function token() {
|
|
if (defined('BEEP_UPDATE_TOKEN') && BEEP_UPDATE_TOKEN) {
|
|
return BEEP_UPDATE_TOKEN;
|
|
}
|
|
return (string) apply_filters('beep_update_token', '');
|
|
}
|
|
|
|
protected function api_url($path) {
|
|
return $this->api_base . '/api/v1/repos/' . $this->repo . $path;
|
|
}
|
|
|
|
/**
|
|
* Fetch + cache the latest release. Returns an info array or null.
|
|
*/
|
|
protected function get_remote($force = false) {
|
|
if (!$force) {
|
|
$cached = get_site_transient(self::TRANSIENT);
|
|
if ($cached !== false) {
|
|
return is_array($cached) ? $cached : null; // '' means "checked, nothing"
|
|
}
|
|
}
|
|
|
|
$headers = ['Accept' => 'application/json'];
|
|
$token = $this->token();
|
|
if ($token) {
|
|
$headers['Authorization'] = 'token ' . $token;
|
|
}
|
|
|
|
$res = wp_remote_get($this->api_url('/releases/latest'), [
|
|
'timeout' => 15,
|
|
'headers' => $headers,
|
|
]);
|
|
|
|
if (is_wp_error($res) || wp_remote_retrieve_response_code($res) !== 200) {
|
|
// Brief negative cache so a flaky/unauthorized server doesn't stall every admin page.
|
|
set_site_transient(self::TRANSIENT, '', HOUR_IN_SECONDS);
|
|
return null;
|
|
}
|
|
|
|
$data = json_decode(wp_remote_retrieve_body($res), true);
|
|
if (!is_array($data) || empty($data['tag_name'])) {
|
|
set_site_transient(self::TRANSIENT, '', HOUR_IN_SECONDS);
|
|
return null;
|
|
}
|
|
|
|
$info = $this->parse_release($data);
|
|
set_site_transient(self::TRANSIENT, $info, self::CACHE_HOURS * HOUR_IN_SECONDS);
|
|
return $info;
|
|
}
|
|
|
|
/** Normalize a Gitea release payload into the fields we need. */
|
|
protected function parse_release($data) {
|
|
$version = ltrim((string) $data['tag_name'], 'vV');
|
|
|
|
// Prefer an attached .zip asset; fall back to the source archive.
|
|
$package = '';
|
|
if (!empty($data['assets']) && is_array($data['assets'])) {
|
|
foreach ($data['assets'] as $asset) {
|
|
if (!empty($asset['browser_download_url'])
|
|
&& strtolower(substr($asset['name'], -4)) === '.zip') {
|
|
$package = $asset['browser_download_url'];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!$package) {
|
|
$package = !empty($data['zipball_url'])
|
|
? $data['zipball_url']
|
|
: $this->api_base . '/' . $this->repo . '/archive/' . $data['tag_name'] . '.zip';
|
|
}
|
|
|
|
return [
|
|
'version' => $version,
|
|
'package' => $package,
|
|
'changelog' => isset($data['body']) ? (string) $data['body'] : '',
|
|
'published_at' => isset($data['published_at']) ? $data['published_at'] : '',
|
|
'name' => isset($data['name']) ? $data['name'] : '',
|
|
'html_url' => isset($data['html_url']) ? $data['html_url'] : ($this->api_base . '/' . $this->repo),
|
|
];
|
|
}
|
|
|
|
/** Inject our plugin into the WordPress update transient. */
|
|
public function check_for_update($transient) {
|
|
if (!is_object($transient)) {
|
|
return $transient;
|
|
}
|
|
|
|
$info = $this->get_remote();
|
|
if (!$info) {
|
|
return $transient;
|
|
}
|
|
|
|
$has_update = version_compare($info['version'], $this->version, '>');
|
|
|
|
$obj = (object) [
|
|
'slug' => $this->plugin_slug,
|
|
'plugin' => $this->plugin_file,
|
|
'new_version' => $has_update ? $info['version'] : $this->version,
|
|
'url' => $info['html_url'],
|
|
'package' => $has_update ? $info['package'] : '',
|
|
'icons' => [],
|
|
'banners' => [],
|
|
'tested' => '',
|
|
'requires_php' => '',
|
|
];
|
|
|
|
if ($has_update) {
|
|
$transient->response[$this->plugin_file] = $obj;
|
|
} else {
|
|
// Listing it as no_update keeps "View details" / auto-update UI working.
|
|
if (!isset($transient->no_update)) {
|
|
$transient->no_update = [];
|
|
}
|
|
$transient->no_update[$this->plugin_file] = $obj;
|
|
}
|
|
|
|
return $transient;
|
|
}
|
|
|
|
/** Provide the "View details" popup content. */
|
|
public function plugin_info($result, $action, $args) {
|
|
if ($action !== 'plugin_information') {
|
|
return $result;
|
|
}
|
|
if (empty($args->slug) || $args->slug !== $this->plugin_slug) {
|
|
return $result;
|
|
}
|
|
|
|
$info = $this->get_remote();
|
|
if (!$info) {
|
|
return $result;
|
|
}
|
|
|
|
$meta = get_file_data(BEEP_FILE, [
|
|
'Name' => 'Plugin Name',
|
|
'Author' => 'Author',
|
|
'RequiresWP' => 'Requires at least',
|
|
'RequiresPHP' => 'Requires PHP',
|
|
]);
|
|
|
|
return (object) [
|
|
'name' => $meta['Name'] ?: 'Beep',
|
|
'slug' => $this->plugin_slug,
|
|
'version' => $info['version'],
|
|
'author' => esc_html($meta['Author']),
|
|
'homepage' => $info['html_url'],
|
|
'download_link' => $info['package'],
|
|
'requires' => $meta['RequiresWP'],
|
|
'requires_php' => $meta['RequiresPHP'],
|
|
'last_updated' => $info['published_at'],
|
|
'sections' => [
|
|
'changelog' => $this->format_changelog($info['changelog']),
|
|
],
|
|
];
|
|
}
|
|
|
|
/** Very small Markdown → HTML pass for the changelog body. */
|
|
protected function format_changelog($md) {
|
|
if ($md === '') {
|
|
return 'No changelog provided for this release.';
|
|
}
|
|
$html = esc_html($md);
|
|
$html = preg_replace('/^###\s+(.*)$/m', '<h4>$1</h4>', $html);
|
|
$html = preg_replace('/^##\s+(.*)$/m', '<h3>$1</h3>', $html);
|
|
$html = preg_replace('/^[\*\-]\s+(.*)$/m', '<li>$1</li>', $html);
|
|
$html = preg_replace('/(<li>.*<\/li>)/s', '<ul>$1</ul>', $html);
|
|
return wpautop($html);
|
|
}
|
|
|
|
/**
|
|
* Attach the auth token to any request aimed at our Gitea host so the
|
|
* API check AND the package download both authenticate on a private repo.
|
|
*/
|
|
public function authorize_request($args, $url) {
|
|
$token = $this->token();
|
|
if (!$token) {
|
|
return $args;
|
|
}
|
|
$host = wp_parse_url($this->api_base, PHP_URL_HOST);
|
|
$url_host = wp_parse_url($url, PHP_URL_HOST);
|
|
if ($host && $url_host && strcasecmp($host, $url_host) === 0) {
|
|
if (empty($args['headers']) || !is_array($args['headers'])) {
|
|
$args['headers'] = [];
|
|
}
|
|
$args['headers']['Authorization'] = 'token ' . $token;
|
|
}
|
|
return $args;
|
|
}
|
|
|
|
/**
|
|
* Gitea archives unzip to a folder like "beep" or "sami7777-beep-<hash>".
|
|
* Rename it to the plugin slug so WP updates the existing folder in place.
|
|
*/
|
|
public function fix_source_dir($source, $remote_source, $upgrader, $hook_extra = null) {
|
|
if (!is_array($hook_extra)
|
|
|| empty($hook_extra['plugin'])
|
|
|| $hook_extra['plugin'] !== $this->plugin_file) {
|
|
return $source;
|
|
}
|
|
|
|
global $wp_filesystem;
|
|
if (!$wp_filesystem) {
|
|
return $source;
|
|
}
|
|
|
|
$desired = trailingslashit($remote_source) . $this->plugin_slug . '/';
|
|
if (untrailingslashit($source) === untrailingslashit($desired)) {
|
|
return $source;
|
|
}
|
|
|
|
if ($wp_filesystem->move($source, $desired, true)) {
|
|
return $desired;
|
|
}
|
|
return $source;
|
|
}
|
|
|
|
/** Drop the cache after an update so the new version is reflected at once. */
|
|
public function clear_cache($upgrader = null, $options = []) {
|
|
delete_site_transient(self::TRANSIENT);
|
|
}
|
|
|
|
/** "Check for updates" link on the Plugins screen. */
|
|
public function action_links($links) {
|
|
$url = wp_nonce_url(
|
|
self_admin_url('plugins.php?beep_force_update_check=1'),
|
|
'beep_force_update_check'
|
|
);
|
|
$links[] = '<a href="' . esc_url($url) . '">Check for updates</a>';
|
|
return $links;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle the manual "Check for updates" click: clear cache + force a re-check.
|
|
*/
|
|
add_action('admin_init', function () {
|
|
if (empty($_GET['beep_force_update_check'])) {
|
|
return;
|
|
}
|
|
if (!current_user_can('update_plugins')
|
|
|| !check_admin_referer('beep_force_update_check')) {
|
|
return;
|
|
}
|
|
delete_site_transient(Beep_Updater::TRANSIENT);
|
|
delete_site_transient('update_plugins');
|
|
wp_update_plugins();
|
|
wp_safe_redirect(self_admin_url('plugins.php'));
|
|
exit;
|
|
});
|