Mini Update Server Plugin

Tech Articles | April 27, 2025 | Blog, Coding, FlowMattic, Hoster, Plugins, Wordpress

Following on the work I’ve created with the universal updater drop-in class designed to make WordPress plugin updates easier, by bringing as little baggage along for the ride as possible, a single 8kb drop-in file and a few lines of PHP to initialise that drop-in.

Following a few conversations I’ve had since that was written, I realise that plugin developers struggle wth updates. There are lots of great mini devs out there writing plugins, but them having to be manually updated because the current solutions are costly or don’t run within WordPress, there are some that do that are Free and worth considering (All based on how they work, work with he universal updater drop-in class.

I know about the following:

The above two are probably bulletproof as they all run modified versions of my preferred update server for free plugins, which doesn’t need WordPress WP-Update-Server, and this is what I based the universal updater drop-in class on. They both handle updates, some handle licensing and with a small paid annual addon, Simba even handles Woocommerce sales.

In addition, there are the others I use, such as SureCart (0.5% of transactions fee charged for licensing – on creation), which handles licensing and updates, and for WooCommerce, we have WP Software Licensing who where brilliant when I wrote my own SDK for there plugin with correcting a bug I found and supporting me and even WooCommerce API manager I do have a working SDK for this one too, in a bind I’ve never dealt with there customer service but some of the articles I’ve read seem critical.

I am going to skip EDD as we all know about it and move on to a newer product Hoster by dPlugins this is a really promising product, I’ve developed solutions for it for sales and a drop in SDK, it still has some features I think it needs better secure downloads for updates is one of them but it is probably the best new product I’ve seen in this field for WordPress as a paid product its like $129 for 200 sites.

The product on the above list I am watching the closest is UpdatePulse Server, which has real potential to be a really great product.

So, reviewing what I knew, I felt there could be a bit of support I could offer plugin devs to offer more secure updates with a performant, simple update server that works within WordPress, with what I know about all the above.

I must admit this was one of the fastest projects I’ve ever worked on; I knew exactly what I wanted from the beginning: WordPress native custom post types like in Hoster, with output and speed similar to wp-update-server, so that it works with the Universal updater.

I wanted it to be usable for free, and if you already have license checking ability, the ability to supply a remote URL which will return a zip file if the details check the domain and key

So for example, in the following format:
https://licenses.example.com/check?license={key}&plugin={slug}&site={domain}

With {domain}, {Slug} and {Key} passing through from the drop-in request, you could build your URL however you want, i.e maybe licensed-domain is what your site uses for checking. Well, you can just add ={Domain} to it, and it will work.

As well as just straight serving a quick but secure plugin update for free devs by selecting it from the media library, or equally adding a S3 / CDN remote URL.

WordPress native metadata allows you to update it in similar ways to how I do in this article, meaning you can use tools such as SyncBack Pro and scripts to update the version without ever having to leave your machine.

So below is how this works. At the end of the article will be the code used, which is a simple plugin which you could use as a snippet. it’s very small.

So this is what the updater looks like once activated, a new custom post type of updates (1) is created, and this allows you to add posts to this post type of plugins (2). The idea is each post is a plugin.

Each post is made up of a title and some meta fields as shown below

So, in the above, we have the following field

  1. plugin Slug
  2. Plugin Current version
  3. WordPress Version tested to
  4. Requires version
  5. Home Page of Plugin
  6. remote URL, this is what allows you to license check with the passed-over variables before allowing a download if your plugin is paid, or allows you to serve from a CDN or S3 link
  7. Your WYSIWYG editor for your changelog
  8. Select the file from the media library if you’re not using a remote (the remote URL is first offered unless blank, this won’t serve

It all works exactly like a normal post type, and it effectively only needs your server address to work with the universal drop-in updater or wp-update checker https://github.com/YahnisElsts/plugin-update-checker if you need the additional features.

hen you updates are checked using this, the information above is outputted in the JSON check, which WordPress uses to check for updates, which looks like this

When there is an update and they view the changes this is what they see which is the same as our changelog input

All in all the sever side of this comes to about 9kb in size as a snippet and the plugin part is 11kb plus your small load action

// Define our plugin version
if ( ! defined( 'EXAMPLE_PLUGIN_VERSION' ) ) {
    define( 'EXAMPLE_PLUGIN_VERSION', '1.11' );
}

add_action( 'plugins_loaded', function() {
    $updater_config = [
        'plugin_file' => plugin_basename( __FILE__ ),
        'slug'        => 'example-plugin',
        'name'        => 'Example Plugin',
        'version'     => EXAMPLE_PLUGIN_VERSION,
        'key'         => 'testkey123',                     // your updater key
        'server'      => 'https://delighted-crocodile-de1708.instawp.xyz/',
    ];

    // Include the UUPD drop‑in (copy updater.php into inc/updater.php)
    require_once __DIR__ . '/inc/updater.php';
    new UUPD_Updater( $updater_config );
} );
PHP

So it is really small and should be quite performant as it’s not loading anything non native to WordPress or doing anything complex. This has been designed to be quick, easy, and simple to implement for even new developers.

The Code below is the entire update server, a simple update has been made to UUPD to allow this to pass domain details as part of the update.

<?php
/**
 * Plugin Name: UUPD Server
 * Description: Provides a custom post type and secure file download or remote redirect for Universal Updater (UUPD_Updater), with placeholder support and referer-based domain detection.
 * Version: 1.0.1
 * Author: Stingray82
 * Text Domain: uupd-server
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class UUPD_Server {
    const TOKEN_EXPIRY = 3600;

    public function __construct() {
        add_action( 'init', [ $this, 'register_cpt' ] );
        add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
        add_action( 'save_post_update', [ $this, 'save_meta' ], 10, 2 );
        add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_media' ] );
        add_action( 'template_redirect', [ $this, 'handle_requests' ] );
    }

    public function register_cpt() {
        register_post_type( 'update', [
            'label'    => __( 'Updates', 'uupd-server' ),
            'public'   => false,
            'show_ui'  => true,
            'supports' => [ 'title' ],
        ] );
    }

    public function add_meta_boxes() {
        add_meta_box( 'uupd_meta', __( 'Update Details', 'uupd-server' ), [ $this, 'render_meta_box' ], 'update', 'normal', 'default' );
    }

    public function render_meta_box( $post ) {
        wp_nonce_field( 'uupd_save', 'uupd_nonce' );
        $m = get_post_meta( $post->ID );
        foreach ( [ 'slug','version','tested','requires','homepage','remote_url' ] as $f ) {
            $$f = $m[ $f ][0] ?? '';
        }
        $changelog = $m['changelog_html'][0] ?? '';
        $attachment_id = intval( $m['file_id'][0] ?? 0 );
        ?>
        <table class="form-table">
            <?php foreach ( [ 'slug','version','tested','requires','homepage','remote_url' ] as $f ): ?>
            <tr>
                <th><label for="uupd_<?php echo $f; ?>"><?php echo ucfirst( str_replace('_',' ', $f) ); ?></label></th>
                <td><input type="text" id="uupd_<?php echo $f; ?>" name="uupd_<?php echo $f; ?>" value="<?php echo esc_attr( $$f ); ?>" class="regular-text"></td>
            </tr>
            <?php endforeach; ?>
            <tr>
                <th><label for="uupd_changelog_html"><?php _e( 'Changelog', 'uupd-server' ); ?></label></th>
                <td><?php wp_editor( $changelog, 'uupd_changelog_html', [ 'textarea_name' => 'uupd_changelog_html', 'textarea_rows' => 10 ] ); ?></td>
            </tr>
            <tr>
                <th><label><?php _e( 'File', 'uupd-server' ); ?></label></th>
                <td>
                    <input type="hidden" id="uupd_file_id" name="uupd_file_id" value="<?php echo $attachment_id; ?>" />
                    <button type="button" class="button" id="uupd_file_button"><?php _e( 'Select or Upload File', 'uupd-server' ); ?></button>
                    <p class="description" id="uupd_file_name"><?php echo $attachment_id ? esc_html( get_the_title( $attachment_id ) ) : ''; ?></p>
                </td>
            </tr>
        </table>
        <p><?php _e( 'Remote URL may include {slug}, {key}, {domain} placeholders and takes precedence if set.', 'uupd-server' ); ?></p>
        <?php
    }

    public function enqueue_media() {
        global $post;
        if ( $post && $post->post_type === 'update' ) {
            wp_enqueue_media();
            wp_add_inline_script( 'jquery', "jQuery(function($){var frame;$('#uupd_file_button').on('click',function(e){e.preventDefault();if(frame)frame.open();else{frame=wp.media({title:'Select File',button:{text:'Use this file'},multiple:false});frame.on('select',function(){var a=frame.state().get('selection').first().toJSON();$('#uupd_file_id').val(a.id);$('#uupd_file_name').text(a.filename);});frame.open();}});});" );
        }
    }

    public function save_meta( $post_id, $post ) {
        if ( ! isset( $_POST['uupd_nonce'] ) || ! wp_verify_nonce( $_POST['uupd_nonce'], 'uupd_save' ) ) return;
        foreach ( [ 'slug','version','tested','requires','homepage','remote_url' ] as $f ) {
            if ( isset( $_POST['uupd_' . $f] ) )
                update_post_meta( $post_id, $f, sanitize_text_field( $_POST['uupd_' . $f] ) );
        }
        if ( isset( $_POST['uupd_changelog_html'] ) )
            update_post_meta( $post_id, 'changelog_html', wp_kses_post( $_POST['uupd_changelog_html'] ) );
        if ( isset( $_POST['uupd_file_id'] ) )
            update_post_meta( $post_id, 'file_id', intval( $_POST['uupd_file_id'] ) );
        update_post_meta( $post_id, 'last_updated', current_time( 'mysql' ) );
    }

    public function handle_requests() {
        $action = $_GET['action'] ?? '';
        if ( $action === 'get_metadata' ) {
            $this->output_metadata();
        } elseif ( $action === 'download' ) {
            $this->serve_download();
        }
    }

    private function output_metadata() {
        $slug   = sanitize_text_field( $_GET['slug']   ?? '' );
        $key    = sanitize_text_field( $_GET['key']    ?? '' );

        // Domain: explicit > referer host > server host
        if ( ! empty( $_GET['domain'] ) ) {
            $domain = sanitize_text_field( $_GET['domain'] );
        } elseif ( ! empty( $_SERVER['HTTP_REFERER'] ) ) {
            $ref = wp_parse_url( $_SERVER['HTTP_REFERER'], PHP_URL_HOST );
            $domain = $ref ?: sanitize_text_field( $_SERVER['HTTP_HOST'] ?? '' );
        } else {
            $domain = sanitize_text_field( $_SERVER['HTTP_HOST'] ?? '' );
        }

        $posts = get_posts([ 'post_type'=>'update','meta_key'=>'slug','meta_value'=>$slug ]);
        if ( ! $posts ) wp_send_json_error('Invalid slug',404);
        $post = $posts[0];

        $meta = [];
        foreach([ 'slug','version','tested','requires','homepage','changelog_html','last_updated' ] as $f ) {
            $meta[ $f ] = get_post_meta( $post->ID, $f, true ) ?: '';
        }

        $remote = get_post_meta( $post->ID, 'remote_url', true );
        if ( $remote ) {
            $meta['download_url'] = $this->build_remote_url( $remote, $slug, $key, $domain );
        } else {
            $file_id = get_post_meta( $post->ID,'file_id',true );
            if ( $file_id ) {
                $expires = time() + self::TOKEN_EXPIRY;
                $data    = "{$slug}|{$file_id}|{$expires}";
                $token   = hash_hmac('sha256',$data,AUTH_KEY);
                $meta['download_url'] = esc_url_raw( add_query_arg([
                    'action'=>'download', 'slug'=>$slug, 'expires'=>$expires,
                    'token'=>$token, 'key'=>$key, 'domain'=>$domain,
                ], site_url('/') ) );
            }
        }

        header('Content-Type: application/json; charset=UTF-8');
        $json = wp_json_encode( $meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
        $json = str_replace(['<','>'], ['<','>'], $json);
        echo $json;
        exit;
    }

    private function build_remote_url( $template, $slug, $key, $domain ) {
        $replacements = [
            '{slug}'   => rawurlencode( $slug ),
            '{key}'    => rawurlencode( $key ),
            '{domain}' => rawurlencode( $domain ),
        ];
        return esc_url_raw( strtr( $template, $replacements ) );
    }

    private function serve_download() {
        $slug    = sanitize_text_field($_GET['slug']??'');
        $expires = intval($_GET['expires']??0);
        $token   = sanitize_text_field($_GET['token']??'');
        if ( time() > $expires ) wp_die('Download link expired',403);
        $posts = get_posts(['post_type'=>'update','meta_key'=>'slug','meta_value'=>$slug]);
        if ( ! $posts ) wp_die('Invalid slug',404);
        $post = $posts[0];
        $file_id = get_post_meta($post->ID,'file_id',true);
        if ( ! $file_id ) wp_die('No file available',404);
        $data = "{$slug}|{$file_id}|{$expires}";
        if ( ! hash_equals(hash_hmac('sha256',$data,AUTH_KEY),$token) ) wp_die('Invalid token',403);
        $file = get_attached_file($file_id);
        if ( ! file_exists($file) ) wp_die('File not found',404);
        header('Content-Description: File Transfer');
        header('Content-Type: application/octet-stream');
        header('Content-Disposition: attachment; filename="'.basename($file).'"');
        header('Expires: 0');header('Cache-Control: must-revalidate');header('Pragma: public');
        header('Content-Length: '.filesize($file));
        readfile($file);exit;
    }
}

new UUPD_Server();

PHP

The video below lets you see the above in action!

Support the Author

buy me a coffee
Really Useful Plugin Logo
Wpvideobank temp
Appoligies for any spelling and grammer issue. As a dyslexic i need to rely on tools for this they like me are not perfect but I do try my best