Controller with File Upload

Synopsis

Almost all of my projects deal with image-, video- or audiofiles. This article explains how to create a controller that provides CRUD functionality for resources with file uploads.

The following modules are used:

  • Moose::Role
  • HTML::FormHandler
  • DBIx::Class::InflateColumn::FS
  • DBIx::Class::Ordered

and some more. This article has 3 attached Moose roles which you can download and put in lib/CatalystX/...

Uploaded files will be delivered using the X-Sendfile header (http://www.catalystframework.org/calendar/2009/16)

Moose Action Roles

Because I want different controllers to have the same functionality and I want to stay DRY I use Moose::Role to compose some actions into my controller. The roles are named Resource.pm and SortableResource.pm

You can download these files.

This is how my lib structure looks like:

lib/
  CatalystX
    Role
    Sendfile.pm
    TraitFor
    Controller
      Resource.pm
      SortableResource.pm
    MyApp/
      MyApp.pm
      MIMETypes.pm

This leads to almost no code in the actual controller files.

Let's create a Blog Application.

Catalyst Configuration

lib/Blog.pm

    __PACKAGE__->config(
        name => 'Blog',
        # Disable deprecated behavior needed by old applications
        disable_component_resolution_regex_fallback => 1,
        default_view    => 'TT',
        default_model   => 'DB',
        session => { flash_to_stash => 1 },
        'Model::DB' => {
            fs_path      => __PACKAGE__->path_to( qw/ root static media/ ),
            schema_class => 'Blog::Schema',
            connect_info => {
                dsn         => 'dbi:Pg:database=blog',
                user        => 'blog',
                password    => 'blog_password',
                AutoCommit  => 1,
            },
        },
        ...
    );

A Blog has many Articles and eachArticle can have Media attached.

Articles Result

    package Blog::Schema::Result::Articles;

    use strict;
    use warnings;

    use base 'DBIx::Class';

    __PACKAGE__->load_components(qw/
        Ordered
        InflateColumn::Object::Enum
        InflateColumn::DateTime
        TimeStamp
        Core
    /);

    __PACKAGE__->table("articles");
    __PACKAGE__->add_columns(
        "id",
        {  
            data_type => "INTEGER",
            is_auto_increment => 1,
            is_numeric => 1,
            is_nullable => 0,
        },
        "title",
        {  
            data_type => "TEXT",
            is_nullable => 0,
        },
        "body",
        {  
            data_type => "TEXT",
            is_nullable => 0,
        },
        "position",
        {  
            data_type => "INTEGER",
            default_value => 0,
            is_nullable => 1,
        },
    );

    __PACKAGE__->resultset_attributes({ order_by => 'position' });
    __PACKAGE__->position_column('position');
    __PACKAGE__->add_unique_constraint([ qw/ position / ]);

    __PACKAGE__->set_primary_key("id");
   
    __PACKAGE__->has_many(
        'media',  
        'Blog::Schema::Result::Media',
        'article_id',
        { cascade_delete => 1 }
    );

    # overide delete to make sure the associated file is deleted aswell
    sub delete {
        my $self = shift;
        $self->media->delete_all;
        $self->next::method();
    }
   
    1;

Articles Controller

    package Blog::Controller::Articles;
    use Moose;
    use namespace::autoclean;
    use Blog::Form::Articles::Create;

    BEGIN { extends 'Catalyst::Controller' }

    with 'Blog::Role::SortableResource';
    __PACKAGE__->config(
        resultset_key   => 'articles_rs',
        resources_key   => 'articles',
        resource_key    => 'article',
        model           => 'DB::Articles',
        form_class      => 'Blog::Form::Articles::Create',
        template        => 'articles/form.tt',
        actions         => {
            base => { PathPart => ['article'], Chained => [''], CaptureArgs => 0 },
        },
    );

    __PACKAGE__->meta->make_immutable;

    1;

Articles Form

The form configuration:

    package Blog::Form::Articles::Create;
    
    use HTML::FormHandler::Moose;
    extends 'HTML::FormHandler::Model::DBIC';
    use namespace::autoclean;
    
    has '+item_class' => ( default => 'Articles' );
    has '+enctype' => ( default => 'multipart/form-data');
    
    has_field 'title' => (
        type => 'Text',
        required => 1,
        size => 25,
        minlength => 5,
    );
      
    has_field 'body' => (
        type => 'TextArea',
        required => 1,
        minlength => 5,
    );

    has_field 'submit' => ( id => 'btn_submit', type => 'Submit', value => 'Submit' );
    
    __PACKAGE__->meta->make_immutable;
    
    1;

So far we can create Articles. Next step is adding a Controller for Media that can be added to Articles.

Media Result

First we need the database resultsource:

    package Blog::Schema::Result::Media;
   
    use strict;
    use warnings;
   
    use base 'DBIx::Class';
   
    __PACKAGE__->load_components(qw/
        Ordered
        InflateColumn::Object::Enum
        InflateColumn::DateTime
        InflateColumn::FS
        TimeStamp
        Core
    /);
   
    __PACKAGE__->table("media");
    __PACKAGE__->add_columns(
        "id",
        {
            data_type => "INTEGER",
            is_auto_increment => 1,
            is_numeric => 1,
            is_nullable => 0,
        },
        "article_id",
        {
            data_type => "INTEGER",
            is_numeric => 1,
            is_nullable => 0,
        },
        "title",
        {
            data_type => "TEXT",
            is_nullable => 0,
        },
        "description",
        {
            data_type => "TEXT",
            is_nullable => 1,
        },
        "position",
        {
            data_type => "INTEGER",
            default_value => 0,
            is_nullable => 1,
        },
        "file",
        {
            data_type => "TEXT",
            is_fs_column => 1,
            #fs_column_path => '/tmp', # we set this value in config in lib/MyApp.pm
        },
        "content_type",
        {
            data_type => "varchar",
            size => 32,
            is_nullable => 0,
        },
        "mediatype",
        {
            data_type => "varchar",
            is_nullable => 0,
            is_enum => 1,
            extra => { list => [qw/image audio video/] },
        },
        "created",
        {
            data_type       => 'datetime',
            set_on_create   => 1,
            is_nullable     => 0,
        },
        "updated",
        {
            data_type       => 'datetime',
            set_on_update   => 1,
            set_on_create   => 1,
            is_nullable     => 0,
        },
    ); 
     
    __PACKAGE__->resultset_attributes({ order_by => 'position' });
    __PACKAGE__->position_column('position');
    __PACKAGE__->add_unique_constraint([ qw/ position / ]);

    __PACKAGE__->set_primary_key("id");

    __PACKAGE__->belongs_to(
       "article",
       "article",
       "Blog::Schema::Result::Articles",
       "article_id",
    );

    1;

X-Sendfile

The Media controller consumes the Sendfile role.

First install the apache xsendfile module. If you use debian that's easy.

aptitude install libapache2-mod-xsendfile

Then you need to enable X-Sendfile support in your apache config like so:

XSendFile On

Media Controller

The Sendfile role injects a $self->sendfile($c, $filename[, $content_type]) method into the controller.

    package Blog::Controller::Media;
    use Moose;
    use namespace::autoclean;
    use Blog::Form::Media::Create;

    BEGIN { extends 'Catalyst::Controller' }

    with 'Blog::Role::Sendfile';
    with 'Blog::Role::SortableResource';
    __PACKAGE__->config(
        parent_key          => 'article',
        parents_accessor    => 'media',
        resultset_key       => 'media_rs',
        resources_key       => 'media',
        resource_key        => 'm',
        model               => 'DB::Media',
        form_class          => 'Blog::Form::Media::Create',
        template            => 'media/form.tt',
        actions             => {
            base => { PathPart => ['media'], Chained => ['/articles/base_with_id'], CaptureArgs => 0 }, 
        },
    );
    
    # set the file param so HTML::FormHandler can work its magic
    before 'form' => sub { 
        my ( $self$c$media ) = @_;
        if ($c->req->method eq 'POST') {
            $c->req->params->{'file'} = $c->req->upload('file');
        }
    };
    
    sub send : Chained('base_with_id') PathPart('send') Args(0) {
        my ( $self$c ) = @_;
        my $media = $c->stash->{m};
        $self->sendfile($c$media->file$media->content_type);
    }

    __PACKAGE__->meta->make_immutable;
   
    1;

With this controller you can serve images/videos/songs/... with the following link format:

<img alt="[% m.title %]" src="[% c.uri_for(c.controller('Media').action_for('send'), [ article.id, m.id ] ) %]" />

Media Form

Here is the Form source: Blog/Form/Media/Create.pm

    package Blog::Form::Media::Create;

    use HTML::FormHandler::Moose;
    extends 'HTML::FormHandler::Model::DBIC';
    use namespace::autoclean;

    has '+item_class' => ( default => 'Media' );
    has '+enctype' => ( default => 'multipart/form-data');

    has_field 'title' => (
        type => 'Text',
        required => 1,
        size => 25,
        minlength => 5,
    );

    has_field 'description' => (
        type => 'TextArea',
        required => 1,
        minlength => 5,
    );
    
    has_field 'file' => (
        type => 'Upload',
        required => 1,
        max_size => 10000000,
    );

    has_field 'submit' => ( id => 'btn_submit', type => 'Submit', value => 'Submit' );

    # after validation we want { file => $filehandle }
    # instead of { file => $catalyst_request_upload }
      sub validate {
        my $self = shift;
        if (defined($self->field('file')->value)) {
            my $fh = $self->field('file')->value->fh;
            $self->field('file')->value($fh);
        }
    }
   
    use MIME::Types;
    has '_mime_types' => (
        is => 'ro',
        default => sub {
            my $mime = MIME::Types->new( only_complete => 1 );
            $mime->create_type_index;
            $mime;
        }
    );

    before 'update_model' => sub {
        my $self = shift;
        my $item = $self->item;
        my $file = $self->params->{file};
    
        return unless($file); # file field in HFH is inactive    
        use MIMETypes;
        my $mime = MIMETypes::MIMEfromFile($file->basename);
        my ($type$subtype) = split('/'$mime);
        $item->content_type($mime);
    $item->mediatype($type);
    };
    
    __PACKAGE__->meta->make_immutable;
    
    1;

Prerequisites

One more thing to note here. For this code to work you need to read and apply the instructions for passing config to your schema.

Credits

t0m, gshank, semifor, rafl, castaway, caelum, hobbs, mst, ... and probably some more.

My tags:
 
Popular tags:
  Upload Role
Powered by Catalyst
Powered by MojoMojo Hosted by Shadowcat - Managed by Nordaaker