diff --git a/README.md b/README.md index 11044f4..62abc3b 100755 --- a/README.md +++ b/README.md @@ -57,6 +57,35 @@ This is the easiest way to let a small circle into your Beep feed without settin Swap the bird/wordmark in `assets/`. The accent color is `#1D9BF0`; find/replace in `assets/beep.css` for a different palette. +## Updates + +Beep updates like a normal WordPress plugin — the "update available" badge appears +in **Plugins → Updates** and one-click update works. New versions are pulled from +the Gitea repo's **Releases**, not WordPress.org. + +**One-time setup** (private repo) — add to `wp-config.php`: + +```php +define('BEEP_UPDATE_TOKEN', 'your-gitea-access-token'); +// Optional overrides (defaults shown): +// define('BEEP_UPDATE_GITEA_BASE', 'https://git.sami'); +// define('BEEP_UPDATE_REPO', 'sami7777/beep'); +``` + +The token is only read from `wp-config.php` (or the `beep_update_token` filter) — it +is never stored in the repo. Give it read access to the repo. + +**To ship an update:** + +1. Bump the version in **both** places in `beep.php` (the `Version:` header and + `BEEP_VERSION`), commit, and push. +2. On Gitea, create a **Release** whose tag is the new version (`v1.7.0` or `1.7.0`). + Optionally attach a `beep.zip` asset; otherwise the source archive is used. + +Within ~12 hours (or immediately via the **Check for updates** link on the Plugins +screen) every site running Beep will see the update and can install it in one click. +The release description is shown as the changelog in the "View details" popup. + ## What's not in this version Deliberate omissions to keep the plugin understandable: diff --git a/assets/beep.css b/assets/beep.css index 282877e..8515a0c 100755 --- a/assets/beep.css +++ b/assets/beep.css @@ -709,6 +709,24 @@ .beep-embed .beep-feed-header { padding: 12px 16px; } } +/* Post footer: wordmark + delete, pinned bottom-right (delete furthest right) */ +.beep-embed .beep-post-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-top: 6px; +} +.beep-embed .beep-post-wordmark { + height: 16px; + width: auto; + opacity: 0.55; + flex: 0 0 auto; +} +.beep-embed .beep-reply-footer .beep-post-wordmark { + height: 13px; +} + /* Delete */ .beep-embed .beep-delete-row { display: flex; justify-content: flex-end; margin-top: 6px; } diff --git a/beep.php b/beep.php index 9f3bc8e..e7220ef 100755 --- a/beep.php +++ b/beep.php @@ -2,7 +2,7 @@ /** * Plugin Name: Beep * Description: A Twitter-style microblog for WordPress, with likes, replies, and Gravatars. Use the [beep_feed] shortcode on any page. - * Version: 1.6.0 + * Version: 1.7.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: Sami Ahmed @@ -14,7 +14,7 @@ if (!defined('ABSPATH')) { exit; } -define('BEEP_VERSION', '1.6.0'); +define('BEEP_VERSION', '1.7.0'); define('BEEP_FILE', __FILE__); define('BEEP_DIR', plugin_dir_path(__FILE__)); define('BEEP_URL', plugin_dir_url(__FILE__)); @@ -31,9 +31,16 @@ require_once BEEP_DIR . 'includes/class-quotes.php'; require_once BEEP_DIR . 'includes/class-polls.php'; require_once BEEP_DIR . 'includes/class-shortcode.php'; require_once BEEP_DIR . 'includes/class-settings.php'; +require_once BEEP_DIR . 'includes/class-updater.php'; require_once BEEP_DIR . 'includes/class-beep.php'; register_activation_hook(__FILE__, ['Beep_Plugin', 'activate']); register_deactivation_hook(__FILE__, ['Beep_Plugin', 'deactivate']); add_action('plugins_loaded', ['Beep_Plugin', 'init']); + +// Self-hosted updates via the Gitea releases API. +// Register in admin and during wp-cron (where WordPress runs its update checks). +if (is_admin() || (defined('DOING_CRON') && DOING_CRON)) { + Beep_Updater::init(); +} diff --git a/includes/class-updater.php b/includes/class-updater.php new file mode 100644 index 0000000..ad149dc --- /dev/null +++ b/includes/class-updater.php @@ -0,0 +1,309 @@ +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', '

$1

', $html); + $html = preg_replace('/^##\s+(.*)$/m', '

$1

', $html); + $html = preg_replace('/^[\*\-]\s+(.*)$/m', '
  • $1
  • ', $html); + $html = preg_replace('/(
  • .*<\/li>)/s', '', $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-". + * 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[] = 'Check for updates'; + 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; +}); diff --git a/includes/helpers.php b/includes/helpers.php index a44dc56..3feee9f 100755 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -267,15 +267,18 @@ function beep_render_post($post) { title="Quote"> - -
    + - + +
    ID, 0); ?> @@ -398,15 +401,18 @@ function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id = comment_ID); ?> - -
    + - + +