Contents


Build a photo storage service in the cloud with PHP and IBM Bluemix, Part 2

Deploy your photo storage app to Bluemix

Comments

This tutorial was written using a previous version of the IBM Bluemix® interface. Given the rapid evolution of technology, some steps and illustrations may have changed.

More and more applications are taking advantage of cheap, reliable object storage in the cloud. With the IBM® Bluemix™ Object Storage service, you too can add cloud-based storage to your application. This tutorial series shows you how to build a photo storage service in the cloud with IBM Bluemix and PHP. It uses the Silex PHP micro-framework to build the application, together with the HybridAuth authentication library for multi-user support and the Bootstrap templating kit for a responsive, mobile-friendly user interface.

Part 1 shows how to create the application stub, enable the user interface, process photo uploads, and start working with photos. Part 2 shows how to deploy the app to IBM Bluemix, as well as how to add support for multiple containers and multiple users.

Run the appGet the code

Step 1. Deploy to IBM Bluemix

Before deploying to Bluemix, make sure that you have a Bluemix account and the Cloud Foundry command-line tool.

  1. Create your application manifest.

    The application manifest file tells Bluemix how to deploy your application. In particular, it specifies the PHP run-time environment ("build pack") that should be used. Create this file at $APP_ROOT/manifest.yml, filling it with the information below.

    ---
    applications:
    - name: photos-[your-initials]
    memory: 256M
    instances: 1
    host: photos-[your-initials]
    buildpack: https://github.com/dmikusa-pivotal/cf-php-build-pack.git

    Remember to update the host and application name to make it unique – for example, by appending your name, initials, or a random number. The application name has to be unique within your Bluemix organization and space, but the host has to be unique across all of Bluemix. I'm using the CloudFoundry PHP build pack, although other alternatives are also available.

  2. Configure the PHP build pack.

    By default, the Cloud Foundry PHP build pack includes some of the most common PHP extensions. The PHP fileinfo extension is not included by default, but is necessary to check the MIME type of the uploaded file. It's quite easy to configure the build pack to add this extension during deployment.

    Create a $APP_ROOT/.bp-config directory and then create $APP_ROOT/.bp-config/options.json with the following content to add support for the PHP fileinfo extension:

    {
        "WEB_SERVER": "httpd",
        "PHP_EXTENSIONS": ["bz2", "zlib", "curl", "mcrypt", "fileinfo"]
    }

    The default Apache web server included in the build pack already comes with mod_rewrite and .htaccess support enabled, so you don't need to do anything else to enable Silex's URL rewriting features.

  3. Connect to BlueMix and deploy the application.

    Use the cf command-line tool to log in to Bluemix using your IBM username and password:

    shell> cf api https://api.ng.bluemix.net
    shell> cf login

    Change to the $APP_ROOT directory and push the application to Bluemix: shell> cf push.

    Here's a sample of what you will see during this process:

    pushing the app to bluemix command output
    pushing the app to bluemix command output
  4. Bind the Object Storage service to your application.

    Your application is now deployed, but you still need to connect it with an Object Storage instance so that it has somewhere to store the uploaded files. To do this, go to the Bluemix administration dashboard and log in with your IBM username and password. Your application is listed in the dashboard:

    Your application listed in the dashboard
    Your application listed in the dashboard

    Select your application and click Add a service or API to add the Object Storage service (in the Data Management category) to your application:

    Add a service window
    Add a service window

    Restage your app. An Object Storage service instance is bound to your application in the Bluemix administration dashboard:

    Object Storage service instance
    Object Storage service instance

    When you inspect your application's environment variables in the Bluemix dashboard, the URI to your Object Storage instance is included in the VCAP_SERVICES environment variable. This information is automatically picked up and used by the application, as discussed previously:

    Environment variables
    Environment variables
  5. Start using your application.

    Access your application by browsing to the host you specified in your application manifest — for example, http://photos-[your-initials].mybluemix.net. Try uploading a few files through the interface:

    Photos listed in the interface
    Photos listed in the interface

    If you see a blank page or other errors, use the link at the top of this section to debug your PHP code and find out where things are going wrong.

At this point, your application is fully functional, albeit with two important constraints: It only supports a single container (default) and a single user account (me). If all you're looking for is a no-frills personal photo storage solution in the cloud, you can stop right here. But if you want to take advantage of all the cloudy goodness that comes with Bluemix Object Storage service, Steps 2 and 3 show how to add support for multiple containers per user and multiple users. Step 4 shows the code you need to add for user authentication.

Step 2. Add multiple containers support

The PHP client library includes a listContainers() method which returns all the available containers linked to a sub-account. First, use this to update the photo upload form and allow the user to select which container the uploaded file should be saved to. Include a free-text input field, so the user can create new containers as needed.

Here's the updated form template with changes highlighted in bold, which should be saved to $APP_ROOT/views/add.twig:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
  
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>
    <div class="panel panel-default">
      <div class="panel-heading clearfix">
        <h4 class="pull-left">Add Photo</h4>
      </div>
    </div>
    
    <form enctype="multipart/form-data" action="{{ app.request.basepath }}/add" method="post">
      <div class="form-group">
        <div class="input-group">
          <span class="input-group-addon">Select photo</span>
          <input type="file" name="file" class="form-control" style="height: auto" />
        </div>
      </div>

      <div class="form-group">
        <div class="input-group">
          <span class="input-group-addon">Select container</span>
          <select class="form-control" name="container">
            {% for c in containers %}
            <option value="{{ c.name|url_encode }}">{{ c.name }}</option>
            {% endfor %}
          </select>
        </div>
      </div>

      <div class="form-group">
        <div class="input-group">
          <span class="input-group-addon">Use new container</span>
          <input type="text" name="container-new" class="form-control" />
        </div>
      </div>
      
      <div class="form-group">
        <button type="submit" name="submit" class="btn btn-default">Upload</button>
      </div>
   
    </form>

    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>   
  </body>
</html>

And here are the revised GET and POST handlers for the /add route, which take care of listing available containers for display, creating new containers with the createContainer() method, and associating uploaded files with containers with the setObject() method:

<?php
// application initialization and configuration - snipped!

// file upload form
$app->get('/add', function () use ($app) {
  $containers = (array) $app['os']->listContainers();
  return $app['twig']->render('add.twig', array('containers' => $containers));
});

// file upload processor
// get and check uploaded file
// if valid, create container and add file
$app->post('/add', function (Request $request) use ($app) {
  $file = $request->files->get('file');
  $containerNew = $request->get('container-new');
  $containerExisting = $request->get('container');
  $container = (!empty($containerNew)) ? urldecode($containerNew) : urldecode($containerExisting);
  if ($file && $file->isValid()) {
    if (in_array($file->getClientMimeType(), array('image/gif', 'image/jpeg', 'image/png'))) {
      if (!empty($containerNew)) {
        $app['os']->createContainer($container);
      }
      $app['os']->setObject($container, $file->getClientOriginalName(), file_get_contents($file->getRealPath()));
    } else {
      throw new Exception('Invalid image format');
    }
  } else {
    throw new Exception('Invalid upload');
  }
  return $app->redirect($app["url_generator"]->generate('index'));    
});

// other handlers – snipped!

$app->run();

Similarly, the index page template needs an update to support listing and deleting multiple containers. Here's the revision for $APP_ROOT/views/index.twig:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>

    <div class="panel panel-default">
      <div class="panel-heading clearfix">
        <h4 class="pull-left">Photos</h4>
      </div>
    </div>
    
    {% if containers|length > 0 %}
    {% for c in containers %}
    <div class="container">
      <p class="clearfix">
          <span class="btn btn-success"> {{ c.name }} <span class="badge">{{ c.objects|length }}</span></span>
      </p>
      <div class="clearfix">
          <a href="{{ app.request.basepath }}/add" role="button" class="btn btn-primary btn-sm"><span class="glyphicon glyphicon-plus"></span> Add Photo</a>
          <a href="{{ app.request.basepath }}/delete/{{ c.name|url_encode }}" role="button" class="btn btn-primary btn-sm"><span class="glyphicon glyphicon-exclamation-sign"></span> Delete Container</a>
      </div>
      <hr/>
      {% for o in c.objects %}        
      <ul class="list-group row clearfix">
        <li class="list-group-item col-xs-3 clearfix" style="border:none"><img src="{{ o.url }}" class="img-responsive" /></li>
        <li class="list-group-item col-xs-5 clearfix" style="border:none">
          <p> {{ o.name }} </p>
          <h6> {{ o.bytes }} bytes </h6>
        </li>
        <li class="list-group-item col-xs-4 clearfix" style="border:none">
          <p>
            <a href="{{ o.url }}" role="button" class="btn btn-primary btn-sm">View</a> <br/>
          </p>
          <p>
            <a href="{{ app.request.basepath }}/delete/{{ c.name|url_encode }}/{{ o.name }}" role="button" class="btn btn-primary btn-sm">Delete</a>
          </p>          
        </li>
      </ul>
      <hr/>
      {% endfor %}
    </div>
    {% endfor %}
    {% else %}
    <div class="container">
      <h4>No photos found.</h4>
      <a href="{{ app.request.basepath }}/add" role="button" class="btn btn-default btn-sm"><span class="glyphicon glyphicon-plus"></span> Add</a>
    </div>
    {% endif %}
  
    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>   
  </body>
</html>

The index page handler should now also use the listContainers() method to retrieve all available containers before iterating over them individually:

<?php
// application initialization and configuration - snipped!

// index page handler
$app->get('/index', function () use ($app) {
  $containers = (array) $app['os']->listContainers();
  foreach ($containers as &$c) {
    $objects = json_decode($app['os']->listObjects($c['name']));
    foreach ($objects as &$o) {
      $o = (array) $o;
      $o['url'] = $app['os']->getObjectUrl($c['name'], $o['name']);
    }
    $c['objects'] = $objects;
  }
  return $app['twig']->render('index.twig', array(
    'containers' => $containers
    ));
})->bind('index');

// other handlers – snipped!

$app->run();

Finally, the /delete handler also needs a quick tweak to support container (as well as object) deletion:

<?php
// application initialization and configuration - snipped!

// delete handler
// if object provided, delete object
// if container provided, delete objects in container, then delete container
$app->get('/delete/{container}/{object}', function ($container, $object) use ($app) {
  $container = urldecode($container);
  $object = urldecode($object);
  if (empty($object)) {
    $objects = json_decode($app['os']->listObjects($container));
    foreach ($objects as $o) {
      $app['os']->deleteObject($container, $o->name);  
    }
    $app['os']->deleteContainer($container);    
  } else {
    $app['os']->deleteObject($container, $object);  
  }
  return $app->redirect($app["url_generator"]->generate('index'));
})
->value('object', '');

$app->error(function (\Exception $e, $code) use ($app) {
  return $app['twig']->render('error.twig', array('error' => $e->getMessage()));
})
->value('object', '');

// other handlers – snipped!

$app->run();

Here, the /delete route handler checks to see if the route parameters include both a container name and an object name, or only a container name. If the latter, then the deleteObject() method is used as before, to delete the specified object only. If the former, then each object in the specified container is individually deleted using the deleteObject() method and then, once the container is empty, the container itself is deleted using the deleteContainer() method.

If you push the revised code to Bluemix, then you have the ability to create new containers (or select existing ones) when uploading photos, and the list view displays photos categorized by container:

Photo list view
Photo list view

Step 3. Add multiple users support

When it comes to supporting multiple users, the Bluemix Object Storage service actually makes things quite easy. Since the service has its own authentication system and each service instance can support multiple sub-accounts, supporting multiple users only involves creating a separate sub-account for each user on the instance and using this sub-account for all subsequent container and object operations related to the user. Of course, you still need an authentication layer in front to ensure that users can't access each other's sub-accounts...and that's where HybridAuth can help.

HybridAuth is an open-source PHP library that lets you authenticate users against a wide variety of social networks and services, including Facebook, Google+, LinkedIn, and Twitter. HybridAuth abstracts away the nitty-gritty of service authentication and authorization, making it easy to add social sign-on to a PHP application without the complexities of OAuth transactions and access tokens. Here, you use HybridAuth to implement the authentication layer, requiring the user to authenticate against an external service and creating or granting access to his or her sub-account only if authentication succeeds.

To begin using HybridAuth, add it to your project by updating your $APP_ROOT/composer.json file so that it looks like this:

{
    "require": {
        "silex/silex": "*",
        "twig/twig": "*",        
        "ibmjstart/zendservice-openstack": "dev-master",
        "hybridauth/hybridauth": "2.*"
    },
    "minimum-stability" : "dev",
    "prefer-stable": true
}

Install HybridAuth using Composer:

shell> cd /usr/local/apache/htdocs/photos
shell> php composer.phar update

The next step is to decide which social network to use for authentication. To keep things simple for this tutorial, I'll use Google+, although you can just as easily use Twitter, Facebook, MySpace, LinkedIn, Foursquare, or any other supported network. In order to do this, you must first register your web application with Google and give it access to the Google+ API for authentication.

To do this, log in to Google using your Google Account credentials and visit the Google Developers Console. Create a new project, assign it a name, and then turn on access to the Google+ API in the project's APIs section:

Google API section
Google API section

While you're there, obtain an OAuth 2.0 client ID and secret for the application in the project's Credentials section. Make note of these values, as you will need them for HybridAuth:

client ID and secret credentials
client ID and secret credentials

When obtaining the OAuth client ID and secret, set the application redirect URL. This is the URL to which Google will redirect the client browser after completing the OAuth authentication process. For this example, set the URL to your Bluemix domain, followed by the special path /callback?hauth.done=Google – for example, http://photos-[your-initials].mybluemix.net/callback?hauth.done=Google. The special /callback route is defined later within the application.

Note: Remember to review the Google APIs Terms of Service, the Google+ Platform Developer Policies, the Google+Platform Terms of Service, and the Google Privacy Policies to ensure that your application is fully compliant. For example, you will want to give users the option to delete all their data from your system at any time. An example of the necessary code for that is available in the application code repository.

Step 4. Add user authentication code

With all the preliminaries dealt with, it's time to write some code. Begin by configuring HybridAuth in the application by adding the following lines to your $APP_ROOT/index.php file:

<?php
// application initialization - snipped!

// load config file with OAuth secret
require 'config.php';

// define configuration array
// ... for object storage service
$config["storage"] = array(
  'service' => array(
  ),
  'adapter' => array(
    'adapter' => 'Zend\Http\Client\Adapter\Curl',
    'curloptions' => array(CURLOPT_SSL_VERIFYPEER => false, CURLOPT_TIMEOUT => 6000),  
  )
);

// ... for HybridAuth
$config["hybridauth"]  = array(
  "base_url" => "http://photos.mybluemix.net/callback",
  "providers" => array (
  "Google" => array (
    "enabled" => true,
    "keys" => array (
      "id" => $oauth_id,
      "secret" => $oauth_secret
    ),
    "scope" => "https://www.googleapis.com/auth/userinfo.email"
)));

// use BlueMix VCAP_SERVICES environment
if ($services = getenv("VCAP_SERVICES")) {
  $services_json = json_decode($services, true);
  $config["storage"]["service"]["url"] = $services_json["objectstorage"][0]["credentials"]["auth_uri"];
  $config["storage"]["service"]["user"] = $services_json["objectstorage"][0]["credentials"]["username"];
  $config["storage"]["service"]["key"] = $services_json["objectstorage"][0]["credentials"]["password"];
} else {
  throw new Exception('Not in Bluemix environment');
}

// start session
session_start();

// initialize HybridAuth client
$auth = new Hybrid_Auth($config["hybridauth"]);

// initialize Silex application
$app = new Application();

// other handlers – snipped!

$app->run();

The code above initializes a new HybridAuth client, which is accessible via the $auth object. The OAuth client ID and secret were already generated for your application in the previous step; they're now loaded from a configuration file which looks like this:

<?php
// config.php
$oauth_id = "YOUR-ID-HERE";
$oauth_secret = "YOUR-SECRET-HERE";

Obviously, replace the values above with the correct ones for your setup.

Next, add some routes for logging in and out, together with the special /callback route that is needed for OAuth authentication:

<?php
// application initialization and configuration – snipped!

// other handlers – snipped!

// login handler
// check if authenticated against provider
// retrieve user email address and save to session
$app->get('/login', function () use ($app, $auth) {
  $google = $auth->authenticate("Google");
  $currentUser = $google->getUserProfile();
  $_SESSION['uid'] = $currentUser->email;
  return $app->redirect($app["url_generator"]->generate('index'));
})
->bind('login');

// logout handler
// log out and display logout information page
$app->get('/logout', function () use ($app, $auth) {
  $auth->logoutAllProviders();
  session_destroy();
  return $app['twig']->render('logout.twig');
});
// OAuth callback handler
$app->get('/callback', function () {
  return Hybrid_Endpoint::process();
});

$app->run();

Here's a quick overview of the changes:

  • The /login handler uses the HybridAuth client's authenticate() method to authenticate the user against the Google+ API. Don't worry about how -- the authenticate() method abstracts away all the complexity of OAuth authorization and authentication. Once authenticated, the result object's getUserProfile() method returns the authenticated user's profile in a structured format. This profile includes the user's name, profile URL, photo URL, and other personal information, but for the purpose of this application, all you really need is the email address associated with the user to serve as your unique user identifier. This email address is then stored in the $_SESSION['uid'] session variable and the client is forwarded to the application index page.
  • The /callback handler is required for the OAuth authentication flow, as it is the URL to which Google will redirect the client browser after completing the OAuth authentication process. You don't really need to worry about this one either, as the Hybrid_Endpoint::process() method abstracts away the work of handling the callback.
  • The /logout handler uses the HybridAuth object's logoutAllProviders() method to log out all connected providers. It also destroys the current application session and renders the following page template, which should be saved as $APP_ROOT/views/logout.twig.
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    
    <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
      <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script>
      <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
    <![endif]-->
  </head>
  <body>
    <div class="panel panel-default">
      <div class="panel-heading clearfix">
        <h4 class="pull-left">Log out</h4>
      </div>
    </div>    
    <div class="container">
      You are now signed out of the application. However, since you originally signed in using your Google Account, you must also sign out of your Google Account to completely destroy your session.
      <br/>
      <a href="{{ app.request.basepath }}/index" role="button" class="btn btn-default">Back</a>
      <a href="https://accounts.google.com/Logout" role="button" class="btn btn-default">Sign out of Google</a>
    </div>

    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>   
  </body>
</html>

As the page template above points out, logging out from the application still leaves the Google OAuth session active. The logout page therefore offers the user an additional option to also log out of the connected Google Account and completely expire the OAuth token and session.

Since the $_SESSION['uid'] variable will only exist if a user is successfully authenticated, it can be used to protect access to the other application routes:

<?php
// register authentication middleware
$authenticate = function (Request $request, Application $app) use ($config) {
  if (!isset($_SESSION['uid'])) {
    return $app->redirect($app["url_generator"]->generate('login'));
  }
  $config["storage"]["service"]["url"] .= '/' . str_replace(array('@', '.'), '_', $_SESSION['uid']);
  $app['os'] = new ObjectStorage(
    $config["storage"]["service"],
    new Zend\Http\Client('', $config["storage"]["adapter"])
  );    
};

The $authenticate function above checks for the presence of the user identifier in the session. If it's not present, it redirects the user to the /login route, forcing a re-login. If it is present, the Object Storage authentication URL is updated to automatically include the user's email address as the sub-account name on every request. Consequently, every operation — listing containers, adding or deleting photos — will be performed in that sub-account only, allowing multiple users to store photos in the service without overlap.

The $authenticate function is used as Silex middleware: It is automatically run before a request is processed by attaching it to the corresponding route handler using the before() method. This makes it possible to restrict access and only allow authenticated users to request those application routes. If you look at the source code of the application, you can see this middleware in use on the /index, /add, /delete, and /logout handlers.

To see how it works, try browsing to your application URL as before at http://photos-[your-initials].mybluemix.net. This time, instead of being taken directly to the photo list, you are prompted to log in to your Google account. After that, an OAuth authorization screen opens:

Authorization screen
Authorization screen

You are transferred to the application's main page only after you grant access to your profile in the screen above. Note that the first time you log in with a new account, it may take a few minutes for the application's main page to load as the Bluemix Object Storage service provisions the new sub-account.

Conclusion

This tutorial focused on the Bluemix Object Storage service, which makes it easy to add reliable, scalable cloud storage to your desktop or mobile web application. By combining this service with the Bluemix platform, some PHP programming, and a mobile-friendly user interface, developers can quickly and efficiently build powerful end-user and enterprise applications to backup and manage files in the cloud from any device.

If you'd like to experiment with the Bluemix Object Storage service, start by trying out the live demo of the application. Then, download the code and take a closer look to see how it all fits together. You can also refer to the links at the top of each section to learn more about the Bluemix Object Storage API, the Silex micro-framework, and the other tools and techniques used in this tutorial. Happy coding!


Downloadable resources


Related topics


Comments

Sign in or register to add and subscribe to comments.

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=1
Zone=Cloud computing
ArticleID=1009174
ArticleTitle=Build a photo storage service in the cloud with PHP and IBM Bluemix, Part 2
publish-date=06262015