CakePHP Tutorial: Build a file sharing application

Code

The honeymoon is over baby. Now it’s time for the real work to begin. This iteration of our CakePHP tutorial series will result in your very own file-sharing tool. This is handy in the situation that you have a file you need to send to a business partner or client, or share with a friend, but you still want to retain control over who gets access to each specific file. For example, you may only want a client to access a file for a week, so we’ll build a system where you can remove that access at any time. What’s more, it’ll be quick, easy, and extensible once we’re done.

For each file, we want to associate it with a user for ownership, but we also want to be able to associate it with many other users for sharing (read access). So we need to define two separate associations from file to user. We’re going to manage the sharing access with an association called “Has and belongs to many” or HABTM for short. HABTM utilises a join table to associate the two records, and has a standard within CakePHP that we’ll follow so the framework can do all the heavy lifting for us:

CREATE DATABASE `fileshare`;
USE `fileshare`;

CREATE TABLE `uploads` (
	`id` CHAR(36) NOT NULL PRIMARY KEY,
	`user_id` CHAR(36) NOT NULL,
	`title` VARCHAR(45) NOT NULL,
	`description` TEXT,
	`filename` VARCHAR(255) NOT NULL,
	`filesize` INT(11) UNSIGNED NOT NULL DEFAULT 0,
	`filemime` VARCHAR(45) NOT NULL DEFAULT 'text/plain',
	`created` DATETIME,
	`modified` DATETIME
);

CREATE TABLE `users` (
	`id` CHAR(36) NOT NULL PRIMARY KEY,
	`username` VARCHAR(45) NOT NULL,
	`password` VARCHAR(255) NOT NULL,
	`email` VARCHAR(255) NOT NULL,
	`created` DATETIME,
	`modified` DATETIME
);

The join table we mentioned identifies users that have been allowed access to specified files. The standards specify that a join table for a HABTM association is to be the tables in their plural form, joined with an underscore and listed in alphabetical order:

CREATE TABLE `uploads_users` (
  `id` CHAR(36) NOT NULL PRIMARY KEY,
  `upload_id` CHAR(36) NOT NULL,
  `user_id` CHAR(36) NOT NULL
);

Create your tables in a new database, and we’ll push on to baking the project and getting the basics running.

Ready, set… bake!

Bake is a special console command that ships with CakePHP, and is run from the console. In order for this to work effectively the cake/console path within the CakePHP package needs to be in your PATH environment variable. You can achieve this with the following in your shell: $ export PATH=”$PATH:/path/to/cakephp/cake/console” And of course you’ll need PHP available on the command line (usually available in a php-cli package, or similar). To create the skeleton structure for your project, run the following in your shell:

$ cake bake project fileshare
$ cd fileshare

Next, configure the app to connect to the database. Change the settings as shown in the following code in your config/database.php file to the appropriate settings for your databaseL

class DATABASE_CONFIG {
	var $default = array(
		'driver' => 'mysql',
		'persistent' => false,
		'host' => 'localhost',
		'login' => 'username',
		'password' => 'password',
		'database' => 'fileshare',
		'prefix' => '',
	);
}

Setting up the “default” connection is enough. And while we’re baking and having CakePHP handle all the hard work, lets bake the controllers, models and views to get the basis of our project in place.

$ cake bake all user
$ cake bake all upload

What we have at this stage is a completely ready-to-use website that associates users with many uploads and enables us to populate the database in an easy-to-use manner. But thus far, there’s no actual file upload capability, so we need to modify what CakePHP has baked for us to provide additional functionality processing the file uploads and to prevent people from adding and modifying other users’ information. Further to that, we want to secure the whole application so that only authenticated users can modify data.

Locking things down

We already have a ‘users’ table, and the user addition is working as part of the baking process if you browse to your application and append users to the URL. For example: http://localhost/fileshare/users. So what do we need to do? Well, passwords at this stage are being stored in cleartext, and we don’t seem to be asked to log in. Open up the app_controller.php file in the root of your project. This is an empty controller from which all the other controllers in the application inherit functionality. Anything we implement here will be used in all our controllers, so this is the perfect place to enforce logins. Add the Auth and Session components. Your AppController will now look like this:

<?php
class AppController extends Controller {
  var $components = array(‘Auth’, ‘Session’);
}
?>

If you try to refresh your users list, you’ll be presented with an error indicating that the login action doesn’t exist. If you look carefully, the URL has also been changed to /users/login. Fortunately all the juicy hard stuff has been implemented in CakePHP for us, so all we need to do is provide the action (function) on the controller, and a login form for users to view. Open up controllers/users_controller.php and add the login action:

function login() {
}

That’s not a mistake, it’s an empty function, and it’s all we need for authentication to log in on the controller side.

Class Conflicts

Without the availability of PHP namespaces, we can’t name classes the same as any that might be used from the CakePHP framework. Since CakePHP provides a File class, we’ve had to avoid calling our model in this example “File”, as a conflict would arise with class naming. As such, “Upload” has been used in its place. For a list of all classes in CakePHP, see the public API: http://api.cakephp.org.

Before we get too ahead of ourselves, we need to ensure that we can register a user in case you have not already registered one. Since we’re about to complete the user authentication section, we’d be locked out of all functionality and if we didn’t make an exception for the user registration page, we’d never be able to get into our funky new system. Create a beforeFilter method on the users controller, and add the following to tell the Auth component that we’re allowed to visit the register page even if we have not yet logged in:

function beforeFilter() {
  $this->Auth->allow(‘add’);
  return parent::beforeFilter();
}

Create the login view in a new file: views/users/login.ctp, as described here:

<div class="users form">
<?php echo $this->Form->create('User');?>
	<fieldset>
 		<legend><?php __('Login'); ?></legend>
	<?php
	echo $this->Form->input('username');
	echo $this->Form->input('password');
	?>
	</fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>

Refresh the page that was giving an error message, and you’ll be presented with a login form. Notice that you will be allowed to visit the /users/add page, but everything else will be redirected to the /users/login page. Feel free at this point to register yourself a user. It will be useful in testing further points in the application. Take a moment to check out your database. You’ll notice that the password is hashed for us automatically. Awesome! It’s also handy to clean up some of the automatically generated fields and inputs that we won’t be using, to ensure that users only have access to things we permit. So remove the following input from both the user add and edit views in views/users/add.ctp and views/users/edit.ctp:

echo $this->Form->input(‘Upload’);
The user index is accessible until authentication is added.

The user index is accessible until authentication is added.

Enabling file uploads

It’s high time we got some file uploading action happening. First up, let’s make an uploads directory under the project directory to place our uploaded files, then assign ownership to the user running your web server. On some distributions this user is www-data, on others it’s apache or www. You’ll need to specify the appropriate user for your system.

$ mkdir uploads
$ chown www-data uploads

Time to dive into some code! Let’s start by adjusting the upload add page to accept files, and store them in a safe manner. The bake process has generated a fairly good form for us, but we’re going to remove a couple of the automatically generated fields and replace them with a file upload field. The columns we defined in our database are primarily for automatic population. The file upload will provide all the data we need. Remove the filename, filesize and filemime inputs from the upload add view in views/uploads/add.ctp, and add in a file input. We didn’t create a column called file in the database, so CakePHP isn’t going to do all the fancy behind-the-scenes work for that field, and we also need to tell it what type that field needs to be for the file input to be generated correctly. The other small change we’re making is to remove the user_id input that CakePHP had generated for us. Instead, we’ll add some code in our controller to automatically assign files to the user who is currently logged in. Your form inputs should now look like this:

<?php echo $this->Form->create(‘Upload’, array(‘type’ => ‘file’));?>
  <fieldset>
     <legend><?php __(‘Add Upload’); ?></legend>
  <?php
  echo $this->Form->input(‘title’);
  echo $this->Form->input(‘description’);
  echo $this->Form->input(‘file’, array(‘type’ => ‘file’));
  echo $this->Form->input(‘User’);
  ?>
  </fieldset>
<?php echo $this->Form->end(__(‘Submit’, true));?>
Enabling file uploads is simple.

Enabling file uploads is simple.

Processing the uploaded file

Sure, we’ve got a file in place, but we actually need to handle the file in some manner to show up an error when the file is not correctly uploaded, and to save it in our prepared writable uploads folder when it’s been completed successfully. For this we open up controllers/uploads_controller.php in the baked project, and modify the function add() to process the file if it’s available. On the third line of this function, the code attempts to save the model data. Modify the if statement to call a function that we’ll write in a moment called uploadFile(): if ($this->uploadFile() && $this->Upload->save($this->data)) {

Sounds easy enough, doesn’t it? Now let’s create the uploadFile() function in the same controller:

function uploadFile() {
  $file = $this->data[‘Upload’][‘file’];
  if ($file[‘error’] === UPLOAD_ERR_OK) {
    $id = String::uuid();
    if (move_uploaded_file($file[‘tmp_name’], APP.’uploads’.DS.$id)) {
      $this->data[‘Upload’][‘id’] = $id;
      $this->data[‘Upload’][‘user_id’] = $this->Auth->user(‘id’);
      $this->data[‘Upload’][‘filename’] = $file[‘name’];
      $this->data[‘Upload’][‘filesize’] = $file[‘size’];
      $this->data[‘Upload’][‘filemime’] = $file[‘type’];
      return true;
    }
  }
  return false;
}

Again, we’re keeping things simple. We upload the file, and if we’re able to move it successfully into place, we return true. In the process of doing this, we are manually generating an ID which we use as a safe filesystem name for storage. This eliminates any issues that users may attempt to introduce with weird and potentially system-damaging filenames that would otherwise be directly stored on your filesystem. Manually generating the UUID with String::uuid() removes this security hole, and ensures a safe file upload, while maintaining the original filename in the database to send to the user when downloading.

The next thing we’re going to integrate is the ability to download files. But before going too far, try adding a couple of files. You’ll see them successfully being added to the database, and if you look at the uploads directory we created, matching ID named files start appearing within. If you experience any issues at this point, ensure that your web server user has write access to the uploads directory.

The other cool thing we’re doing here is assigning the user with $this->Auth->user(‘id’) which is the ID of the currently logged-in user. Since we locked down the security earlier, we know that users need to be logged in to reach this page, so the value will always be present and valid.

Cleaning up associations

You’ll notice that we’ve doubled up on our associations for both the User and Upload model. Take for example the User model in models/user.php; CakePHP has generated a hasMany and a hasAndBelongsToMany association both with the index Upload. This just won’t do, because the clash in naming will cause incorrect data to output in the views. Change the hasAndBelongsToMany association in the User model to be SharedUpload. And similarly for the Upload model in models/upload.php, change the HABTM association to be SharedUser:

// User Model
var $hasAndBelongsToMany = array(
  ‘SharedUpload’ => array(
    ‘className’ => ‘Upload’,
    ...

// Upload Model
var $hasAndBelongsToMany = array(
  ‘SharedUser’ => array(
    ‘className’ => ‘User’,
      ..

In order for these association key changes to correctly render in the views, you’ll need to change the index referenced on the views in the related section at the bottom of the view indexes. In views/users/view.ctp, the following two lines:

<?php if (!empty($user[‘pload’])):?>
foreach ($user[‘Upload’] as $upload):

are changed to:

<?php if (!empty($user[‘SharedUpload’])):?>
foreach ($user[‘SharedUpload’] as $upload):

At this point you can register a new user, log in to the system, upload files and assign users. We’ve achieved a massive amount of functionality for minimal code and effort. Give the system a good test before moving on the the next step.

Viewing and downloading files

If you have played around with the navigation in the views we have already, you’ll have come across the view page for one of your uploaded files. It shows all the meta information about the file, but at this point you can’t retrieve the file itself. Let’s tweak a few things to enable downloads so we can get at the files. The first thing to do is add a link to the view file views/uploads/view.ctp. You can add this wherever you like (I prefer to add it at the bottom of the existing data):

<dt<?php if ($i % 2 == 0) echo $class;?>><?php __(‘Download’); ?></dt>
<dd<?php if ($i++ % 2 == 0) echo $class;?>>
  <?php echo $this->Html->link(__(‘Download’, true), array(‘action’ => ‘download’, $upload[‘Upload’][‘id’])); ?>
   
</dd>

With the link created, it’s time to get our hands dirty again and put together the action for the controller to handle the file download. Open up your UploadsController in controllers/uploads_controller.php one more time and add the following download function. This forces download, and returns the file to the user with the original filename that was present when the file was uploaded. Here's the code:

function download($id = null) {
	if (!$id) {
		$this->Session->setFlash(__('Invalid id for upload', true));
		$this->redirect(array('action' => 'index'));
	}
	$this->Upload->bindModel(array('hasOne' => array('UploadsUser')));
	$upload = $this->Upload->find('first', array(
		'conditions' => array(
			'Upload.id' => $id,
			'OR' => array(
				'UploadsUser.user_id' => $this->Auth->user('id'),
				'Upload.user_id' => $this->Auth->user('id'),
			),
		)
	));
	if (!$upload) {
		$this->Session->setFlash(__('Invalid id for upload', true));
		$this->redirect(array('action' => 'index'));
	}
	$this->view = 'media';
	$filename = $upload['Upload']['filename'];
	$this->set(array(
		'id' => $upload['Upload']['id'],
		'name' => substr($filename, 0, strrpos($filename, '.')),
		'extension' => substr(strrchr($filename, '.'), 1),
		'path' => APP.'uploads'.DS,
		'download' => true,
	));
}

Tutorial Code

The code for this article has been made available on GitHub under my account http://github.com/predominant/cakephp_linux_format. You can grab the code from here if you had any issues with the code generation through the bake facility, or if you just want to get the application up and running, you can clone the code without going through each of the steps in the article.

You can now try out your download link, and you’ll get the file being downloaded via your browser! There’s a fair bit going on in this function, but believe me, there’s far more being taken care of by CakePHP that you don’t need to worry about. We’re first inspecting the ID to ensure it was supplied. Next, we try to lookup an upload record from the database with that ID, and while we’re in there, we ensure that the ID of the upload is associated with the currently logged-in user, or that the file was originally uploaded by the user who is logged in. In either case, we are allowed access. If that query failed, the user is attempting to access a file to which they have not specifically been granted permission, so we redirect them to the index list of files.

Showing only what users can access

In order to make the file list make sense for all users, you’ll also need to modify the index() action on the Uploads controller, to perform similar filtering to show only the files that are permitted in the users rendered list, otherwise it’ll clog up with files they don’t actually have access to see! Adjust the index action thus:

function index() {
  $this->Upload->bindModel(array(‘hasOne’ => array(‘UploadsUser’)), false);
  $this->paginate = array(
    ‘conditions’ => array(
      ‘OR’ => array(
        ‘UploadsUser.user_id’ => $this->Auth->user(‘id’),
        ‘Upload.user_id’ => $this->Auth->user(‘id’),
      ),
    )
  );
  $this->set(‘uploads’, $this->paginate());
}

We’ve had to add the false parameter on the bindModel() call this time to ensure that the pagination result came out correctly. Pagination takes two separate database results to return the data. The first of these determines the number of elements in the table that match the query, and the second actually retrieve the data. The false parameter tells CakePHP to retain the binding beyond a single query. The simple rule, of course, is that if you are using bindModel and pagination, you need to add false on the end.

The view() action also benefits from the same filtering:

function view($id = null) {
  if (!$id) {
    $this->Session->setFlash(__(‘Invalid upload’, true));
$this->redirect(array(‘action’ => ‘index’));
  }

  $this->Upload->bindModel(array(‘hasOne’ => array(‘UploadsUser’)));
  $upload = $this->Upload->find(‘first’, array(
    ‘conditions’ => array(
      ‘Upload.id’ => $id,
      ‘OR’ => array(
        ‘UploadsUser.user_id’ => $this->Auth->user(‘id’),
        ‘Upload.user_id’ => $this->Auth->user(‘id’),
      ),
    )
  ));
  if (!$upload) {
    $this->Session->setFlash(__(‘Invalid upload’, true));
    $this->redirect(array(‘action’ => ‘index’));
  }
  $this->set(‘upload’, $upload);
}

Wrapping it up

The only last nicety that would be required to take this site live is a logout function. And I’ll be kind enough to give you the code to put on the UsersController for logout:

public function logout() {
  $this->redirect($this->Auth->logout());
}

There’s no view required, and redirection happens as soon as the user hits the /users/logout URL, after destroying the session and logging out the user.

So we’ve built a secure, file-uploading, multi-user sharing web application, and it’s only taken 20 minutes or so to get it all done. From here, you can add extra functionality such as thumbnail icons to preview content that users have uploaded, or a different sharing mechanism to select users without exposing the complete user list to everyone. You can also enable email notifications for users that you are sharing files with. I hope you’ve enjoyed this tutorial, and I hope it’s useful for you either as a case study, or as a deployable solution for client file sharing.

The additional link enables us to download files that have been uploaded.

The additional link enables us to download files that have been uploaded.

We'll be following this up with even more CakePHP tutorials soon - stay tuned!

First published in Linux Format

First published in Linux Format magazine

You should follow us on Identi.ca or Twitter


Your comments

other tutorials?

Where can I find a list of your CakePHP tutorials? When I search for cakephp, I don't even find this tutorial I am commenting on.

Help

Hello,when i used this tutorial for my project, I couldn't download excel or rar file. Can you show me fix this problem?
Thank for any reply.

Thanks for this nice and

Thanks for this nice and very simple toturial!!!

Doubt

I am not sure how we are inserting data in uploads_users table? If we are not doing the same then how can I share file with other users?

my form doesn't save when a file is not selected

my form doesn't save when a file is not selected

you are awesome!

hay man,
extremely well written tutorial! thanks so much!

Everything working, but Download

Hi, First I need to thank you for a wonderful tutorial. I have followed it and have sucessfully got files to upload. The problem is when I try downloading. It does not work. I am uses the latest stable release of CakePHP (2.2.3). The doc says, this->view has been deprecated. I don't know if that is the reason it does not work. Please help.

Thanks,
Imran

Download not working

Hi there

Thank you very much for the tutorial.....The only problem that i have is the download which is not working when i click on it...redirects me to the uploads view page and no downloading is taking place.

I am using CakePHP(2.2.3). Your assistance would be greatly appreciated.

Thanks
Victor

For those receiving view errors...

... changing $this->view = 'media' to $this->viewClass = 'Media' made this code work for me. :)

Image View in Browser

Thank you very much for the tutorial.
How can I view image in browser.?

awesome

Very good tutorial, thanks man! Why isn't it on Cakephp official doc?

Image View in Browser

Hi, Thanks for the tut! I realy would be very pleased when i can view the image in the browser too.
Maybe you can elaborate on this?

Deleting Association

You forgot the modifications to the delete action. As it stands in your tutorial, only DB records can be deleted, leaving linked file behind. Pasted below is a simple modification+addition to allow for proper.

Two functions to add to UploadsController.php:

//DELETE FILE IF IT EXISTS
public function delete_file($id){
$path = $this->get_real_path($id);

if(file_exists($path)){
unlink($path);
return true;
}

return false;
}

//RETURN PHYSICAL LOCATION OF FILE
public function get_real_path($id){
return APP.'uploads'.DS.$id;
}

Similar to the modification to the "add" function mentioned above we add "$this->delete_file($id)" to the line in the delete function containing "if($this->Upload->delete())". So the final result being:

if ($this->delete_file($id) && $this->Upload->delete()) {

...

}

problem with download file

hi... thanks for the tutorial. i having a problem at download function. i can download the file. but the file extension is not right. it became pic-.htm when i upload pic.png. can anyone help me please. tq...

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.

Post new comment

CAPTCHA
We can't accept links (unless you obfuscate them). You also need to negotiate the following CAPTCHA...

Username:   Password:
Create Account | About TuxRadar