Writing a download manager plugin with WordPress
This article was written in 2014 👴🏼. Most of the tips here are not valid anymore and I don't recommend using any of the snippets displayed here.
I'm keeping this article here for historical reasons and to remind myself how much I've improved over the years.
Only cosmetic changes were made to this article.
I have always said that you can do almost anything using WordPress thanks to all the built-in functions and APIs that we have at our disposal. However, seems like for some of them the documentation is still in early stages.
I have had the need to use these APIs in the past, but almost never I have used them all together. So in hopes of learn a little bit more about them, I decided to write a simple plugin using them, and document my progress in here.
I will write a simple download manager, called Mah Download Manager (patent pending!). This plugin should help me upload files, store them in the database, and display a list of the saved data.
In order to do this, we'll use three of the APIs in WordPress, Filesystem, Database and List Table. So let's begin.
Part I – Define the plugin and set up the upload function
This article is a bit advanced, so I assume you at least have some knowledge about writing a WordPress plugin, that's why I won't get into specific details, let's just get started:
mah-plugin-manager.php
1<?php2/**3 * Plugin Name: Mah Download Manager4 * Plugin URI: https://github.com/emeaguiar/mah-download-manager5 * Description: A simple download manager for WordPress6 * Version: 1.07 * Author: Mario Aguiar8 * Author URI: https://www.marioaguiar.net9 * License: GPL210 */11class Mah_Download_Manager {1213 function __construct() {14 add_action( 'admin_menu', array( $this, 'register_menu_pages' ) );15 }1617 function register_menu_pages() {18 add_menu_page( 'Mah Download manager', 'Downloads', 'manage_options', 'mah-download-manager', array( $this, 'display_menu_page' ), 'dashicons-download', 12 );19 }2021 function display_menu_page() {2223?>2425“>2627This space will display a list of files…2829<?php30 }31}3233$mah_download_manager = new Mah_Download_Manager;34
Here I'm defining a very basic structure, I add the meta data of the plugin, and an admin page where I will display a list of all our files later on. Right now is empty, so let's setup another page to add the files and then we'll come back to this one.
1add_submenu_page( 'mah-download-manager', __( 'Add new file', 'mah-download-manager' ), __( 'Add new file', 'mah-download-manager' ), 'upload_files', 'mah-download-manager/new', array( $this, 'display_add_new_page' ) );2
First, we create a new submenu page where we will put our upload form, this new
page will only be visible to people who can upload files, and will display whatever
we put on the display_add_new_page
function.
1function display_add_new_page() {2 if ( $this->form_is_submitted() ) {3 return;4 }5?>67 : ” name=”mdm-upload”>89<?php10 }11
Simple form, we just added one field to select our file, a submit button and a
nonce field to make sure we are submitting the form from the right place, we also
check whether or not our form is being processed, in case it is, do not display
the form. Since we are leaving our action
attribute empty, the form will be processed
in the same function, unless we perform this check.
1function form_is_submitted() {2 if ( empty( $_POST ) ) {3 return false;4 }5 check_admin_referer( 'mah-download-manager' );67 $mdm_form_fields = array( 'mdm-file', 'mdm-upload' );8 $mdm_method = '';910 if ( isset( $_POST[ 'mdm-upload' ] ) ) {11 $url = wp_nonce_url( 'mah-download-manager/new', 'mah-download-manager' );12 if ( ! $creds = request_filesystem_credentials( $url, $mdm_method, false, false, $mdm_form_fields ) ) {13 return true;14 }1516 if ( ! WP_Filesystem( $creds ) ) {17 request_filesystem_credentials( $url, $mdm_method, true, false, $mdm_form_fields );18 return true;19 }2021 $fileTempData = $_FILES[ 'mdm-file' ];2223 $this->upload_file( $fileTempData );24 }25 return true;26 }27
Now, if the form is empty, we don't need to continue in this function, so return
false
. check_admin_referer
checks if the nonce field we added to the form is valid
and if it is, we are ready to perform our magic with the Filesystem API.
First we make sure we have submitted the form by checking the value of the upload button, then we prepare a new nonce to go along our upload screen.
request_filesystem_credentials
checks if we have the credentials to upload files
depending on the method we use to perform the upload, in this case I left $mdm_method
blank, WordPress by default will use FTP access. If we don't have the credentials
WordPress will display another screen asking for them.
Once WordPress has confirmed we can write in the server we can save the file temporary data in a variable and then go to our upload function.
1function upload_file( $file ) {2 $file = ( ! empty( $file ) ) ? $file : new WP_Error( 'empty_file', __( "Seems like you didn't upload a file.", 'mah-download-manager' ) );34 if ( is_wp_error( $file ) ) {5 wp_die( $file->get_error_message(), __( 'Error uploading the file.', 'mah-download-manager' ) );6 }78 $fileTempDir = $file[ 'tmp_name' ];9 $filename = trailingslashit( $this->uploadsDirectory[ 'path' ] ) . $file[ 'name' ];1011 $response = $this->move_file( $fileTempDir, $filename );1213 if ( is_wp_error( $response ) ) {14 wp_die( $response->get_error_message(), __( 'Error uploading the file.', 'mah-download-manager' ) );15 }1617 wp_redirect( admin_url( 'admin.php?page=mah-download-manager&message=1' ) );18 exit;19}20
First I make sure our $file
variable is not empty, if it is, something went wrong
in the process and i use WP_Error
to let the user know about the issue.
If there's no error I prepare the path where the file is going to be saved using
$this->uploadsDirectory[ 'path' ]
and our file's name and extension in $file[ 'name' ]
.
Then we move our file to it's new permanent location on the server, and wait for the response, if the response of our next function is successful, we'll redirect the user back to the main screen and display him a success message, if not, we'll display another error.
1function move_file( $from, $to ) {2 global $wp_filesystem;3 if ( $wp_filesystem->move( $from, $to ) ) {4 return true;5 } else {6 return WP_Error( 'moving_error', __( "Error trying to move the file to the new location.", 'mah-download-manager' ) );7 }8}9
This function is really simple, we just call the Filesystem API and try to move
the file, if it works then return true
, if it doesn't work return an error.
Remember how earlier I redirected the user along with a success message? When I
registered the main page, you may have noticed I also added this line:
1<?php do_action( 'mdm_display_messages' ); ?>2
This is what we know as a hook, or to be more accurate, this is the place where our hook is going to be executed. The core of WordPress is filled with this beauties, and yes, we can create our own, just like I did here. I created this so it's simpler to add messages. Now let's hook something to it.
1function display_messages() {2 if ( ! isset( $_GET[ 'message' ] || ! intval( $_GET[ 'message' ] ) ) ) {3 return;4 }56 $message = $_GET[ 'message' ];78 switch ( $message ) {9 case 1:10 $class = 'updated';11 $text = __( 'File uploaded succesfully.', 'mah-download-manager' );12 break;13 }1415 echo '1617‘ . $text . ‘1819';20}21
And add the hook to the __construct
function.
1add_action( 'mdm_display_messages', array( $this, 'display_messages' ) );2
Now the page will check if a $message
has been sent, if it has, then it will look
in the list for a match and then it will display the message.
The road so far…
Check out the full source code on my github.
Up until now we have a plugin that allows us to upload and store files in the server, but that's it, we still need to keep track of those files, store their data and display it to the user. First of all let's store the data in our database. Now, there's some ways we could do that, but I think the most suitable way for this plugin is to create our own tables in the database.
Part II – Installing, uninstalling and storing data in the database
The best way to do this is by creating an install function, and hook it to the plugin activation process, this hook looks a little different to the rest, but trust me, it's the right one.
1register_activation_hook( __FILE__, array( $this, 'install' ) );2
That will execute our install()
method only when out plugin is activated, but
will execute it every time our plugin is activated, so let's make sure we only
run the installation process if we haven't done it before, I like to do that by
adding a database version variable, that way it will still be useful in case we
need to upgrade it later on.
1function __construct() {2 $this->mdb_db_version = 1;3...4
Now our install method.
1function install() {2 $current_db_version = get_option( 'mdm_db_version' );3 if ( ! $current_db_version ) {4 global $wpdb;56 $table_name = $wpdb->prefix . "mah_download_manager";78 $charset_collate = '';910 if ( ! empty( $wpdb->charset ) ) {11 $charset_collate = "DEFAULT CHARACTER SET {$wpdb->charset}";12 }1314 if ( ! empty( $wpdb->collate ) ) {15 $charset_collate .= " COLLATE {$wpdb->collate}";16 }1718 $sql = "CREATE TABLE $table_name (19 id mediumint(9) NOT NULL AUTO_INCREMENT,20 date datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,21 name tinytext NOT NULL,22 url varchar(255) DEFAULT '' NOT NULL,23 UNIQUE KEY id (id)24 ) $charset_collate;";2526 require_once ABSPATH . 'wp-admin/includes/upgrade.php';2728 dbDelta( $sql );2930 add_option( 'mdm_db_version', 1 );31 } elseif ( $current_db_version < $this->mdb_db_version ) {32 $this->upgrade( $current_db_version );33 }3435}36
This function does several things, so let's break it down in steps:
- We try to retrieve the database version number from the WordPress options, in case this is the first time we activate the plugin, this won't exist, which means we can install the plugin without any worries.
- Then we call
$wpdb
, the Database API, and setup our table's basic structure.$wpdb->prefix
is the prefix currently used by our installation, and is defined in the wp-config file. - After that we need to manually define our SQL query, with the fields that we will be needing, in this case I chose to store the date, file name and url in the server,
dbDelta()
is the function we will use to execute this query, as is not loaded by default, we need to include the upgrade functions and run the query.- We set up an option in WordPress, this will store the plugin's current version, which will be checked next time the plugin is activated.
- If the version option is present in WordPress, but it's different to the version we set in the plugin, this means we need to upgrade our database, so we run the upgrade script.
The upgrade script is empty as for now, but it will be useful in the future:
1/**2 * Reserved for upgrading purposes3 */4function upgrade( $current ) {56}7
Don't forget, just like we create tables when installing the plugin, we need to delete them when we uninstall it. This is important because we don't want to have lingering options in the website.
uninstall.php
1<?php2 if ( !defined( 'WP_UNINSTALL_PLUGIN' ) )3 exit();45 $option_name = 'mdm_db_version';67 delete_option( $option_name );89 // For site options in multisite10 delete_site_option( $option_name );1112 global $wpdb;1314 $table_name = $wpdb->prefix . "mah_download_manager";1516 $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );17
We remove the options we added, and drop the database tables we created, let's make sure everything is the way it was before we installed our plugin.
Now we need to change our uploading function, so it stores data after a successful upload, we wait for that response and then we redirect the user.
1function upload_file( $file ) {2...3 $file_id = $this->store_data( $file );45 if ( $file_id ) {6 wp_redirect( admin_url( 'admin.php?page=mah-download-manager&message=1' ) );7 exit();8 } else {9 wp_die( 'There was an error saving the data to the database' );10 }1112}13
Our store_data function looks like this:
1function store_data( $file ) {2 global $wpdb;34 $this->table_name = $wpdb->prefix . "mah_download_manager";56 $data = array(7 'name' => sanitize_file_name( $file[ 'name' ] ),8 'type' => sanitize_mime_type( $file[ 'type' ] ),9 'size' => intval( $file[ 'size' ] ),10 'url' => trailingslashit( $this->uploadsDirectory[ 'url' ] ) . $file[ 'name' ],11 'path' => trailingslashit( $this->uploadsDirectory[ 'path' ] ) . sanitize_file_name( $file[ 'name' ] )12 );1314 return $wpdb->insert( $this->table_name, $data );1516}17
We set the table we are using to store the data, and we create the $data
array
to define what data are we going to save in the database. Do NOT forget to sanitize
the data. WordPress already provides us with useful functions to make sure our
data is as safe as possible.
After all that, we run the query it will return the value of our AUTO_INCREMENT
field, in this case, the id
. Or false if there's an error.
If everything went well we will be redirected to the main page of our plugin with a success message, and the data will be available in our database.
The road so far…
Check out the code so far on github.
Now we have a plugin that displays a form to upload files, saves them in our server's uploads folder, and stores the file's data in a custom table inside our database, cool huh? But we still need to actually see that data in our plugin's page, now is only available if we see our uploads folder or we navigate directly to our database, so let's move on.
Part III – Displaying files data in the dashboard
I'm going to use yet another API to display all the records in the database, WP_List_Table
.
You should notice that the use of this class is not recommended by the WordPress team, as it's supposed to be internal use only and it may change at any time without previous notice. However, at the time this article is being written, the class hasn't had any major changes practically since it was written. Still, if you are paranoid, it's safe to include a copy of the class in your plugin, I won't do that at this time.
First of all, we shouldn't access this class directly, we need to extend it using another class. So we need a new file.
class-mah-download-manager-list.php
1<?php2 class Mah_Download_Manager_List extends WP_List_Table {34 }5
We need to overwrite a couple methods in here, starting with prepare_items
,
because we need to have data to show.
1function prepare_items() {2 global $wpdb;34 $table_name = $wpdb->prefix . "mah_download_manager";56 $per_page = $this->get_items_per_page( 'posts_per_page' );78 if ( isset( $_REQUEST[ 'number' ] ) ) {9 $number = (int) $_REQUEST[ 'number' ];10 } else {11 $number = $per_page + min( 8, $per_page );12 }1314 if ( isset( $_REQUEST[ 'start' ] ) ) {15 $start = (int) $_REQUEST[ 'start' ];16 } else {17 $start = ( $page - 1 ) * $per_page;18 }1920 $items = $wpdb->get_results( "SELECT * FROM $table_name ORDER BY date DESC LIMIT $start, $number" );2122 $this->items = array_slice( $items, 0, $number );23 $this->extra_items = array_slice( $items, $number );2425 $total = count( $items );2627 $this->set_pagination_args( array(28 'total_items' => $total,29 'per_page' => $per_page30 ) );3132 $this->_column_headers = array(33 $this->get_columns(),34 array(),35 $this->get_sortable_columns()36 );3738}39
Ok so there are lots of things going on in here, let's break it down:
- First we call the database API (like we did when we stored the data) and specify the table we'll be requesting the data from.
- We set up the number of items we'll show on each page,
get_items_per_page
is a built-in method in the lists API, in this case it will return the same number we have stored on theposts_per_page
option, which can be changed in the “Reading” options of the administration side. - Then we check in which page we are now, and set the limit and offset for our query, this will only return the items we need.
- Now that we have defined the start and end of our query, it's time to run it with
get_results
. - We slice the result and store the items in the class' propertys, we also store the number of results. And set the pagination arguments with it.
- Last but not least, we need to set the headers of our columns,
_column_headers
accepts an array, this must contain the visible headers, the hidden headers, and the sortable headers (in this case I won't add sortable properties, but it is required).
If we try to run the code now, we'll get an error message, that is because
get_columns
is another method we must define ourselves, luckily, it is very simple.
1function get_columns() {2 return array(3 'file' => __( 'File', 'mah_download_manager' ),4 'type' => __( 'Type', 'mah_download_manager' ),5 'size' => __( 'Size', 'mah_download_manager' ),6 'date' => __( 'Date Added', 'mah_download_manager' )7 );8}9
As you can see, we only need to return an array with the names and ids of our headers, no big deal.
Now, there's yet another method we need to define, the one that will display the data of all those columns in the list. Actually, we have a choice to make in here. We can create one default method that handles everything, or we can create multiple methods that handle a specific column.
For the purposes of this article, I'll use both.
1function column_default( $item, $column_name ) {2 switch ( $column_name ) {3 case 'file':4 echo '<a href="' . $item->url . '">' . $item->name . '</a>';5 break;6 default:7 echo $item->$column_name;8 break;9 }10}11
column_default
will be called if there's no other method that handles a
specific column, how does WordPress find it? By using the syntax column_$columnName
,
so if I want a specific method handling the date
column, I do this:
1function column_date( $item ) {2 $m_time = $item->date;3 $time = strtotime( $m_time );4 $time_diff = time() - $time;5 if ( $time_diff > 0 && $time_diff < 24*60*60 ) {6 $h_time = sprintf( __( '%s ago' ), human_time_diff( $time ) );7 } else {8 $h_time = mysql2date( __( 'Y/m/d' ), $m_time );9 }10 echo '<abbr title="' . $m_time . '">' . $h_time . '</abbr>';11}12
And that way column_default
won't be called for the date
column. By the way,
this is a simple function to make the date more readable, I just copied from
the WordPress core and adapted it a bit.
The road so far…
Check out the code so far in github.
That's pretty much what we need to display our lists. So now we have a plugin that displays a form to upload files, saves them to our server, stores the data in a custom table and displays the data in our plugin's main page, the only thing left to do would be to delete those files from our server and database, so let's go on.
Part IV – Deleting files from our server and database
To delete files from our server, we first need to update our list with a delete
link on the files we have, to do that, we'll need to edit our column_default
method and call another method: row_actions
.
class-mah-download-manager-list.php
1function column_default( $item, $column_name ) {2...3 case 'file':4...5 $actions = array(6 'delete' => '<a href="' . add_query_arg( array( 'action' => 'delete', 'file_id' => $item->id ) ) . '">' . __( 'Delete', 'mah_download_manager' ) . '</a>'7 );8 echo $this->row_actions( $actions );9...10
First we create an array with the actions we need, we need to give a name to our
action and define the text or markup that will be displayed. The add_query_arg
function returns the current URL and appends an array as parameters to send,
in this case I add the delete
action with a file_id
value, we'll use this to find
the file in the database and remove it from the server.
Now, what happens after the user clicks on that link? we can create a new page to manage the downloads or we can do it in the same page we are in. Or better yet, just use a hook to look for the action inside this page.
Every time we register a new page, WordPress also registers a hook that only works
on said page, the syntax is toplevel_page_$menuslug
, in this case the slug is
mah-download-manager
so we add the action:
mah-download-manager.php
1function __construct() {2...3 add_action( 'toplevel_page_mah-download-manager', array( $this, 'custom_action' ) );4...5
And we add the functionality:
1function custom_action() {2 if ( ! isset( $_GET[ 'action' ] ) ) {3 return;4 }56 if ( ! isset( $_GET[ 'file_id' ] ) ) {7 wp_die( __( 'You need to select a file to work on!' ) );8 }910 $action = $_GET[ 'action' ];11 $file_id = (int) $_GET[ 'file_id' ];1213 switch ( $action ) {14 case 'delete':15 if ( isset( $_GET[ 'confirm' ] ) && $_GET[ 'confirm' ] == 1 ) {16 $mdm_method = '';1718 $url = wp_nonce_url( 'admin.php?page=mah-download-manager&action=delete', 'mah-download-manager' );19 if ( ! $creds = request_filesystem_credentials( $url, $mdm_method, false, false ) ) {20 return true;21 }2223 if ( ! WP_Filesystem( $creds ) ) {24 request_filesystem_credentials( $url, $mdm_method, true, false );25 return true;26 }2728 $fileTempData = $_FILES[ 'mdm-file' ];2930 $this->delete_file( $file_id );31 } else {32 echo '<p>' . __( 'Are you sure you want to delete this file? This action cannot be reversed.' ) . '</p>';33 echo '<a href="' . add_query_arg( array( 'confirm' => 1 ) ) . '" class="button-primary">' . __( 'Delete anyways', 'mah_download_manager' ) . '</a> ';34 echo '<a href="' . admin_url( 'admin.php?page=mah-download-manager' ) . '" class="button">' . __( 'Cancel' ) . '</a>';35 }36 break;37 default:38 wp_die( __( 'That action is invalid!' ) );39 break;40 }4142}43
Looks like there's a lot of code in here, but don't worry, most of it we've seen before, so let me explain the steps we take:
- First we make sure there's an action defined, if not simply stop running the code.
- Then make sure there's a file selected, if not then there's no use in trying to do anything.
- Then we check which action we are trying to run, if it's not in the list then stop everything. I wrote it this way thinking that in the future we might want to add more actions.
- 1 Inside the
delete
action, we need to make sure the user didn't just simply clicked by accident, so we display a message asking if that's really what he wants to do. - Once we have confirmed that the user really wants to delete the file,
we need to activate the Filesystem API again, and then call the new
method
delete_file
.
- 1 Inside the
1function delete_file( $id ) {2 global $wpdb, $wp_filesystem;3 $table_name = $wpdb->prefix . "mah_download_manager";45 $file_path = $wpdb->get_var( $wpdb->prepare( "SELECT path FROM $table_name WHERE id = %d", $id ) );67 $file_deleted = ( $wp_filesystem->delete( $file_path ) ) ? true : new WP_Error( 'delete_file_error', __( 'There was an error removing the file from the server. Check the path?', 'mah_download_manager' ) );89 $row_deleted = ( $wpdb->delete( $table_name, array( 'id' => $id ) ) ) ? true : new WP_Error( 'delete_row_error', __( 'There was an error removing the data from the database.', 'mah_download_manager' ) );1011 if ( ! is_wp_error( $file_deleted ) && ! is_wp_error( $row_deleted ) ) {12 wp_redirect( admin_url( 'admin.php?page=mah-download-manager&message=2' ) );13 exit;14 } else {15 wp_die( __( 'There were errors while deleting the file.', 'mah_download_manager' ) );16 }1718}19
This method will remove the file from the server and remove the data from our database, here's the breakdown:
- First we call the APIs
$wpdb
to manage the database and$wp_filesystem
to manage the files (this one is only available after activating it previously). - Then we retrieve the file's path from the database, remember to do this before deleting the data!
- Now that we have the path, we remove the file from the server, and check for any errors along the way.
- After deleting the file, it's safe to remove the data from the database, once again, check for errors.
- If there were no errors while deleting the data, then let's redirect the user back to the main page, and add a new message.
Now let's go back to display_messages
and add the new message:
1function display_messages() {2...3 case 2:4 $class = 'updated';5 $text = __( 'The selected file has been removed.', 'mah-download-manager' );6 break;7...8
The end of the road…
Check out the final code in github.
This has been a fun trip! Now we are pretty much done with our plugin, here's the list of features:
- Upload and store files using WordPress' Filesystem API.
- Create and manage data in the database using WordPress' Database API.
- Display a list of the content using WordPress' Lists API.
- Delete existing files from the database and server using Database and Filesystem APIs.
Pretty cool huh? We just created a functional plugin that relies mostly in APIs that are in the core of WordPress, this means that if WordPress works in your server, it's a pretty safe bet to say that this plugin will also work for you.