Implementing a dropbox service with the Blobstore API (Part 1)

The blobstore api is a recent addition to the App Engine platform, and makes it possible to upload and serve large files (currently up to 50MB). It's also one of the most complex APIs to use, as it has several moving parts. This short series will demonstrate how to implement a dropbox type file hosting service on App Engine, using the Blobstore API. To start, we'll cover the basics needed to upload files, keep track of them in the datastore, and serve them back to users.

First up is the upload form. This step is fairly straightforward: We create a standard HTML form, only we generate the URL to post to by calling blobstore.create_upload_url, and passing it the URL of the handler we want called by it. Here's the handler code:

class FileUploadFormHandler(BaseHandler):
  @util.login_required
  def get(self):
    self.render_template("upload.html", {
        'form_url': blobstore.create_upload_url('/upload'),
        'logout_url': users.create_logout_url('/'),
    })

Standard stuff - though it's worth pointing out that, for convenience, we're using the login_required decorator from the google.appengine.ext.webapp.util package to require users to be logged in (and redirect them to the login form if they're not). And here's the template that it renders:

<html>
<head>
  <title>File Hangar: Upload file</title>
</head>
<body>
  <p style="float: right"><a href="{{logout_url}}">Log Out</a></p>
  <h1>Upload a file to App Engine File Hangar</h1>
  <form method="POST" action="{{form_url}}" enctype="multipart/form-data">
    <input type="file" name="file" /><br />
    <input type="submit" value="Upload" />
  </form>
</body>
</html>

Note the attributes on the form element: The method must be POST, and the enctype attribute must be supplied, and must be "multipart/form-data", or the upload will not work!

Next, we want to write the upload handler. First, we need to define a model for storing information about our uploaded file in the datastore. Here's our model:

class FileInfo(db.Model):
  blob = blobstore.BlobReferenceProperty(required=True)
  uploaded_by = db.UserProperty(required=True)
  uploaded_at = db.DateTimeProperty(required=True, auto_now_add=True)

A fairly standard model, with the exception of the 'blob' property, which is of type blobstore.BlobReferenceProperty. A BlobReferenceProperty behaves much like a regular ReferenceProperty, only instead of referencing another model, it references a Blob. On retrieving it, a BlobInfo object is returned.

Here's the code that handles the actual upload:

class FileUploadHandler(blobstore_handlers.BlobstoreUploadHandler):
  def post(self):
    blob_info = self.get_uploads()[0]
    if not users.get_current_user():
      blob_info.delete()
      self.redirect(users.create_login_url("/"))
      return

    file_info = FileInfo(blob=blob_info.key(),
                         uploaded_by=users.get_current_user())
    db.put(file_info)
    self.redirect("/file/%d" % (file_info.key().id(),))

This is a little more complex, so let's step through it. First, this handler extends BlobstoreUploadHandler instead of the regular webapp.RequestHandler class. BlobstoreUploadHandler defines a utility method, self.get_uploads(), which returns a list of BlobInfo objects, one for each uploaded file.

In get(), we call self.get_uploads(), and extract the first element. Because the Blobstore API doesn't currently support multiple uploads in a single form, this will always return exactly one element, so we simply extract that element. Next, we guard against a non-logged-in user somehow submitting the form. We can't use the login_required decorator here, because we need to delete the uploaded file if it was not authorized. Finally, we create a datastore entity for the uploaded file, and redirect the user to the info page on the newly uploaded file.

The next step is to implement the handler that retrieves and shows information about the uploaded file. It's quite straightforward:

class FileInfoHandler(BaseHandler):
  def get(self, file_id):
    file_info = FileInfo.get_by_id(long(file_id))
    if not file_info:
      self.error(404)
      return
    self.render_template("info.html", {
        'file_info': file_info,
        'logout_url': users.create_logout_url('/'),
    })

And here's the template it renders:

<html>
<head>
  <title>File Hangar: File information</title>
</head>
<body>
  <p style="float: right"><a href="{{logout_url}}">Log Out</a></p>
  <h1>File information</h1>
  <table>
    <tr><th>Filename</th><td>{{file_info.blob.filename}}</td></tr>
    <tr><th>Size</th><td>{{file_info.blob.size}} bytes</td></tr>
    <tr><th>Uploaded</th><td>{{file_info.uploaded_at}}</td></tr>
    <tr><th>Uploaded by</th><td>{{file_info.uploaded_by}}</td></tr>
    <tr><th>Content Type</th><td>{{file_info.blob.content_type}}</td></tr>
  </table>
  
  <p><a href="/file/{{file_info.key.id}}/download">Download this file</a></p>
</body>
</html>

Finally, the handler to actually send the file to the user when requested:

class FileDownloadHandler(blobstore_handlers.BlobstoreDownloadHandler):
  def get(self, file_id):
    file_info = FileInfo.get_by_id(long(file_id))
    if not file_info or not file_info.blob:
      self.error(404)
      return
    self.send_blob(file_info.blob, save_as=True)

Here, we fetch the FileInfo entity by key, and, if it exists and the blob it references exists, we call the utility method send_blob, provided by our parent class, BlobstoreDownloadHandler. We pass the extra argument 'save_as' set to True, which ensures the file gets sent for downloading, not for inline display in the browser. We could also supply a filename instead of the value True, to specify the filename we want for it; the default is the name it was uploaded with.

This article only scratched the surface of what we can do with Blobstore serving, and to a large extent, you could find a lot of this in the docs. It's necessary groundwork, however, as in the next post, we'll be doing something all new: Adding support for JavaScript or Flash based upload widgets, for a better user experience!

For those of you following along at home, the full source code of today's demo app can be found here.

Comments

blog comments powered by Disqus